From dcbc659ec1f03968a411740748c444d235ef2a2d Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 10 Mar 2026 09:57:54 -0700 Subject: [PATCH] feat(quotesdb): support ADMIN_AUTH_CODE Cloudflare secret for admin auth Add AppState to handlers, read ADMIN_AUTH_CODE from Worker env, gate reset-auth-code with 409 when secret is active, update tests. Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/infra/worker.tf | 9 ++ quotesdb/src/bin/api/main.rs | 2 +- quotesdb/src/handlers/mod.rs | 263 ++++++++++++++++++++++------------- quotesdb/src/lib.rs | 28 ++-- 4 files changed, 194 insertions(+), 108 deletions(-) diff --git a/quotesdb/infra/worker.tf b/quotesdb/infra/worker.tf index 7914ab3..4006d20 100644 --- a/quotesdb/infra/worker.tf +++ b/quotesdb/infra/worker.tf @@ -28,3 +28,12 @@ resource "null_resource" "worker_deploy" { } } } + +# The ADMIN_AUTH_CODE worker secret must be set before the first deploy. +# Run once (or whenever you need to rotate it): +# +# wrangler secret put ADMIN_AUTH_CODE --config wrangler.toml +# +# When ADMIN_AUTH_CODE is set, it overrides the DB-stored admin code and the +# reset-auth-code endpoint (/api/admin/reset-auth-code) returns 409 Conflict. +# To rotate it, use `wrangler secret put` again — no redeploy needed. diff --git a/quotesdb/src/bin/api/main.rs b/quotesdb/src/bin/api/main.rs index c7eaf1b..dfda415 100644 --- a/quotesdb/src/bin/api/main.rs +++ b/quotesdb/src/bin/api/main.rs @@ -63,7 +63,7 @@ async fn main() { let repo: Arc = Arc::new(repo); - let app = handlers::router(repo); + let app = handlers::router(repo, None); let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); let addr = format!("0.0.0.0:{port}"); diff --git a/quotesdb/src/handlers/mod.rs b/quotesdb/src/handlers/mod.rs index 29a9c5a..8d35ab0 100644 --- a/quotesdb/src/handlers/mod.rs +++ b/quotesdb/src/handlers/mod.rs @@ -31,6 +31,19 @@ use crate::db::{DeleteResult, QuoteRepository}; /// shared across Tokio tasks. `NativeRepository` satisfies both bounds. type Repo = Arc; +/// Shared application state threaded through all Axum handlers. +/// +/// `admin_secret` is `Some` when an `ADMIN_AUTH_CODE` Cloudflare Worker secret +/// is configured. In that case it takes precedence over the DB-stored code and +/// the `reset-auth-code` endpoint is disabled (returning 409). +#[derive(Clone)] +pub struct AppState { + repo: Repo, + /// Optional admin secret injected from the Worker environment. + /// When `Some`, this value is the sole source of truth for admin auth. + admin_secret: Option, +} + // ── Error response helpers ───────────────────────────────────────────────────── /// JSON envelope for all API error responses. @@ -171,8 +184,8 @@ async fn openapi_handler() -> Response { /// /// Returns `500 Internal Server Error` if the database query fails. #[cfg_attr(target_arch = "wasm32", worker::send)] -pub async fn get_status(State(repo): State) -> Response { - match repo.get_submissions_locked().await { +pub async fn get_status(State(state): State) -> Response { + match state.repo.get_submissions_locked().await { Ok(locked) => Json(serde_json::json!({ "submissions_locked": locked })).into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } @@ -187,7 +200,8 @@ pub async fn get_status(State(repo): State) -> Response { /// Returns `400 Bad Request` when date component ordering is violated (e.g. /// `date_after_month` provided without `date_after_year`). #[cfg_attr(target_arch = "wasm32", worker::send)] -async fn list_handler(State(repo): State, Query(params): Query) -> Response { +async fn list_handler(State(state): State, Query(params): Query) -> Response { + let repo = &state.repo; // Validate: month requires year, day requires year+month if params.date_after_month.is_some() && params.date_after_year.is_none() { return error_response( @@ -254,8 +268,8 @@ async fn list_handler(State(repo): State, Query(params): Query /// `GET /api/quotes/:id` in the router to avoid "random" being matched as an /// id parameter. #[cfg_attr(target_arch = "wasm32", worker::send)] -async fn random_handler(State(repo): State) -> Response { - match repo.get_random_quote().await { +async fn random_handler(State(state): State) -> Response { + match state.repo.get_random_quote().await { Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), Ok(None) => error_response(StatusCode::NOT_FOUND, "no quotes in database"), Err(e) => db_error_response(e), @@ -266,8 +280,8 @@ async fn random_handler(State(repo): State) -> Response { /// /// Returns `404` when no quote has the given id. #[cfg_attr(target_arch = "wasm32", worker::send)] -async fn get_quote_handler(State(repo): State, Path(id): Path) -> Response { - match repo.get_quote(&id).await { +async fn get_quote_handler(State(state): State, Path(id): Path) -> Response { + match state.repo.get_quote(&id).await { Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), Ok(None) => error_response(StatusCode::NOT_FOUND, "quote not found"), Err(e) => db_error_response(e), @@ -316,9 +330,12 @@ async fn verify_turnstile(token: &str, secret: &str) -> bool { /// Cloudflare Turnstile token must be provided in the `cf_turnstile_token` /// field. This check is skipped on wasm32 targets (Workers runtime). #[cfg_attr(target_arch = "wasm32", worker::send)] -async fn create_handler(State(repo): State, Json(input): Json) -> Response { +async fn create_handler( + State(state): State, + Json(input): Json, +) -> Response { // Pre-flight: reject new submissions when locked. - match repo.get_submissions_locked().await { + match state.repo.get_submissions_locked().await { Ok(true) => { return ( StatusCode::LOCKED, @@ -349,7 +366,7 @@ async fn create_handler(State(repo): State, Json(input): Json ( StatusCode::CREATED, Json(CreateResponse { quote, auth_code }), @@ -379,14 +396,19 @@ fn extract_admin_code(headers: &HeaderMap) -> Option { .map(|s| s.to_owned()) } -/// Verify that the supplied admin code matches the one stored in the repository. +/// Verify that the supplied admin code matches the configured authority. /// -/// Fetches the current admin code via [`QuoteRepository::get_admin_auth_code`] -/// and compares it with the supplied code using standard string equality. +/// When `state.admin_secret` is `Some`, the secret is compared directly and +/// the database is never queried. When it is `None`, the stored admin code is +/// fetched via [`QuoteRepository::get_admin_auth_code`] and compared with the +/// supplied code using standard string equality. /// Returns `true` if the codes match, `false` if the code is wrong, missing, /// or the database query fails. -async fn verify_admin_code(repo: &Repo, code: &str) -> bool { - match repo.get_admin_auth_code().await { +async fn verify_admin_code(state: &AppState, code: &str) -> bool { + if let Some(secret) = &state.admin_secret { + return secret == code; + } + match state.repo.get_admin_auth_code().await { Ok(Some(stored)) => stored == code, _ => false, } @@ -398,7 +420,7 @@ async fn verify_admin_code(repo: &Repo, code: &str) -> bool { /// `404` if the quote does not exist, or `200` with the updated quote. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn update_handler( - State(repo): State, + State(state): State, Path(id): Path, headers: HeaderMap, Json(input): Json, @@ -407,7 +429,7 @@ async fn update_handler( return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required"); }; - match repo.update_quote(&id, input, &auth_code).await { + match state.repo.update_quote(&id, input, &auth_code).await { Ok(quote) => (StatusCode::OK, Json(quote)).into_response(), Err(e) => db_error_response(e), } @@ -419,7 +441,7 @@ async fn update_handler( /// `404` if not found, or `204 No Content` on success. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn delete_handler( - State(repo): State, + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Response { @@ -427,7 +449,7 @@ async fn delete_handler( return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required"); }; - match repo.delete_quote(&id, &auth_code).await { + match state.repo.delete_quote(&id, &auth_code).await { Ok(DeleteResult::Deleted) => StatusCode::NO_CONTENT.into_response(), Ok(DeleteResult::NotFound) => error_response(StatusCode::NOT_FOUND, "quote not found"), Ok(DeleteResult::Forbidden) => error_response(StatusCode::FORBIDDEN, "forbidden"), @@ -446,14 +468,14 @@ async fn delete_handler( /// /// Returns `403 Forbidden` if the header is missing or the code is incorrect. #[cfg_attr(target_arch = "wasm32", worker::send)] -async fn lock_submissions(State(repo): State, headers: HeaderMap) -> Response { +async fn lock_submissions(State(state): State, headers: HeaderMap) -> Response { let Some(code) = extract_admin_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); }; - if !verify_admin_code(&repo, &code).await { + if !verify_admin_code(&state, &code).await { return error_response(StatusCode::FORBIDDEN, "invalid admin code"); } - match repo.set_submissions_locked(true).await { + match state.repo.set_submissions_locked(true).await { Ok(()) => Json(serde_json::json!({ "submissions_locked": true })).into_response(), Err(e) => db_error_response(e), } @@ -470,14 +492,14 @@ async fn lock_submissions(State(repo): State, headers: HeaderMap) -> Respo /// /// Returns `403 Forbidden` if the header is missing or the code is incorrect. #[cfg_attr(target_arch = "wasm32", worker::send)] -async fn unlock_submissions(State(repo): State, headers: HeaderMap) -> Response { +async fn unlock_submissions(State(state): State, headers: HeaderMap) -> Response { let Some(code) = extract_admin_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); }; - if !verify_admin_code(&repo, &code).await { + if !verify_admin_code(&state, &code).await { return error_response(StatusCode::FORBIDDEN, "invalid admin code"); } - match repo.set_submissions_locked(false).await { + match state.repo.set_submissions_locked(false).await { Ok(()) => Json(serde_json::json!({ "submissions_locked": false })).into_response(), Err(e) => db_error_response(e), } @@ -518,15 +540,25 @@ struct ResetAuthCodeResponse { /// value, which the handler maps to `403`. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn reset_auth_code( - State(repo): State, + State(state): State, headers: HeaderMap, Json(payload): Json, ) -> Response { + if state.admin_secret.is_some() { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "auth code is managed via ADMIN_AUTH_CODE secret — update it with wrangler" + })), + ) + .into_response(); + } let admin_code = match extract_admin_code(&headers) { Some(c) => c, None => return StatusCode::FORBIDDEN.into_response(), }; - match repo + match state + .repo .update_admin_auth_code(&admin_code, payload.new_code.as_deref()) .await { @@ -558,7 +590,7 @@ struct ReportInput { /// `500 Internal Server Error` on a database failure. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn report_handler( - State(repo): State, + State(state): State, Path(id): Path, body: Option>, ) -> Response { @@ -572,7 +604,7 @@ async fn report_handler( ); } - match repo.create_report(&id, reason.as_deref()).await { + match state.repo.create_report(&id, reason.as_deref()).await { Ok(()) => StatusCode::CREATED.into_response(), Err(crate::db::DbError::NotFound) => { error_response(StatusCode::NOT_FOUND, "quote not found") @@ -599,17 +631,17 @@ struct AdminReportsParams { /// is absent or the code is incorrect. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn list_reports_handler( - State(repo): State, + State(state): State, headers: HeaderMap, Query(params): Query, ) -> Response { let Some(code) = extract_admin_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); }; - if !verify_admin_code(&repo, &code).await { + if !verify_admin_code(&state, &code).await { return error_response(StatusCode::FORBIDDEN, "invalid admin code"); } - match repo.list_reports(params.page).await { + match state.repo.list_reports(params.page).await { Ok(result) => (StatusCode::OK, Json(result)).into_response(), Err(e) => db_error_response(e), } @@ -625,17 +657,17 @@ async fn list_reports_handler( /// exist. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn get_quote_reports_handler( - State(repo): State, + State(state): State, Path(quote_id): Path, headers: HeaderMap, ) -> Response { let Some(code) = extract_admin_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); }; - if !verify_admin_code(&repo, &code).await { + if !verify_admin_code(&state, &code).await { return error_response(StatusCode::FORBIDDEN, "invalid admin code"); } - match repo.get_reports_for_quote("e_id).await { + match state.repo.get_reports_for_quote("e_id).await { Ok(result) => (StatusCode::OK, Json(result)).into_response(), Err(crate::db::DbError::NotFound) => { error_response(StatusCode::NOT_FOUND, "quote not found") @@ -655,17 +687,17 @@ async fn get_quote_reports_handler( /// exist. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn admin_delete_quote_handler( - State(repo): State, + State(state): State, Path(quote_id): Path, headers: HeaderMap, ) -> Response { let Some(code) = extract_admin_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); }; - if !verify_admin_code(&repo, &code).await { + if !verify_admin_code(&state, &code).await { return error_response(StatusCode::FORBIDDEN, "invalid admin code"); } - match repo.admin_delete_quote("e_id).await { + match state.repo.admin_delete_quote("e_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(crate::db::DbError::NotFound) => { error_response(StatusCode::NOT_FOUND, "quote not found") @@ -684,17 +716,17 @@ async fn admin_delete_quote_handler( /// exist. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn hide_quote_handler( - State(repo): State, + State(state): State, Path(quote_id): Path, headers: HeaderMap, ) -> Response { let Some(code) = extract_admin_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); }; - if !verify_admin_code(&repo, &code).await { + if !verify_admin_code(&state, &code).await { return error_response(StatusCode::FORBIDDEN, "invalid admin code"); } - match repo.hide_quote("e_id).await { + match state.repo.hide_quote("e_id).await { Ok(()) => Json(serde_json::json!({ "hidden": true })).into_response(), Err(crate::db::DbError::NotFound) => { error_response(StatusCode::NOT_FOUND, "quote not found") @@ -713,17 +745,17 @@ async fn hide_quote_handler( /// exist. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn clear_reports_handler( - State(repo): State, + State(state): State, Path(quote_id): Path, headers: HeaderMap, ) -> Response { let Some(code) = extract_admin_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); }; - if !verify_admin_code(&repo, &code).await { + if !verify_admin_code(&state, &code).await { return error_response(StatusCode::FORBIDDEN, "invalid admin code"); } - match repo.clear_reports("e_id).await { + match state.repo.clear_reports("e_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(crate::db::DbError::NotFound) => { error_response(StatusCode::NOT_FOUND, "quote not found") @@ -745,7 +777,10 @@ async fn clear_reports_handler( /// both bounds via `tokio_rusqlite::Connection`. /// /// [`NativeRepository`]: crate::db::NativeRepository -pub fn router(repo: Arc) -> Router { +pub fn router( + repo: Arc, + admin_secret: Option, +) -> Router { Router::new() // Meta .route("/api/", get(openapi_handler)) @@ -782,7 +817,7 @@ pub fn router(repo: Arc) -> Router { .route("/api/quotes", put(create_handler)) .route("/api/quotes/{id}", post(update_handler)) .route("/api/quotes/{id}", delete(delete_handler)) - .with_state(repo) + .with_state(AppState { repo, admin_secret }) } #[cfg(test)] @@ -1099,7 +1134,7 @@ mod tests { #[tokio::test] async fn test_openapi_endpoint() { - let app = router(MockRepo::empty()); + let app = router(MockRepo::empty(), None); let req = Request::builder() .method(Method::GET) .uri("/api/") @@ -1113,7 +1148,7 @@ mod tests { #[tokio::test] async fn test_list_quotes() { - let app = router(MockRepo::with_quote(sample_quote(), "auth")); + let app = router(MockRepo::with_quote(sample_quote(), "auth"), None); let req = Request::builder() .method(Method::GET) .uri("/api/quotes") @@ -1127,7 +1162,7 @@ mod tests { #[tokio::test] async fn test_random_quote_not_found() { - let app = router(MockRepo::empty()); + let app = router(MockRepo::empty(), None); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") @@ -1139,7 +1174,7 @@ mod tests { #[tokio::test] async fn test_random_quote_found() { - let app = router(MockRepo::with_quote(sample_quote(), "auth")); + let app = router(MockRepo::with_quote(sample_quote(), "auth"), None); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") @@ -1151,7 +1186,7 @@ mod tests { #[tokio::test] async fn test_get_quote_not_found() { - let app = router(MockRepo::empty()); + let app = router(MockRepo::empty(), None); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/nonexistent") @@ -1163,7 +1198,7 @@ mod tests { #[tokio::test] async fn test_get_quote_found() { - let app = router(MockRepo::with_quote(sample_quote(), "auth")); + let app = router(MockRepo::with_quote(sample_quote(), "auth"), None); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/abc-123") @@ -1175,7 +1210,7 @@ mod tests { #[tokio::test] async fn test_create_quote() { - let app = router(MockRepo::empty()); + let app = router(MockRepo::empty(), None); let body = serde_json::json!({ "text": "New quote", "author": "Author", @@ -1198,7 +1233,7 @@ mod tests { /// with `{"error": "submissions are closed"}`. #[tokio::test] async fn test_create_quote_locked_returns_423() { - let app = router(MockRepo::with_submissions_locked(true)); + let app = router(MockRepo::with_submissions_locked(true), None); let body = serde_json::json!({ "text": "Locked quote", "author": "Author", @@ -1220,7 +1255,7 @@ mod tests { /// (existing behaviour is unchanged). #[tokio::test] async fn test_create_quote_unlocked_returns_201() { - let app = router(MockRepo::with_submissions_locked(false)); + let app = router(MockRepo::with_submissions_locked(false), None); let body = serde_json::json!({ "text": "Unlocked quote", "author": "Author", @@ -1248,7 +1283,7 @@ mod tests { repo.set_submissions_locked(false) .await .expect("set_submissions_locked should not fail"); - let app = router(repo); + let app = router(repo, None); let body = serde_json::json!({ "text": "Re-enabled quote", "author": "Author", @@ -1269,7 +1304,7 @@ mod tests { #[tokio::test] async fn test_update_quote_missing_auth() { - let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let app = router(MockRepo::with_quote(sample_quote(), "correct"), None); let body = serde_json::json!({"text": "Updated"}); let req = Request::builder() .method(Method::POST) @@ -1283,7 +1318,7 @@ mod tests { #[tokio::test] async fn test_update_quote_wrong_auth() { - let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let app = router(MockRepo::with_quote(sample_quote(), "correct"), None); let body = serde_json::json!({"text": "Updated"}); let req = Request::builder() .method(Method::POST) @@ -1298,7 +1333,7 @@ mod tests { #[tokio::test] async fn test_update_quote_success() { - let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let app = router(MockRepo::with_quote(sample_quote(), "correct"), None); let body = serde_json::json!({"text": "Updated text"}); let req = Request::builder() .method(Method::POST) @@ -1315,7 +1350,7 @@ mod tests { #[tokio::test] async fn test_delete_quote_missing_auth() { - let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let app = router(MockRepo::with_quote(sample_quote(), "correct"), None); let req = Request::builder() .method(Method::DELETE) .uri("/api/quotes/abc-123") @@ -1327,7 +1362,7 @@ mod tests { #[tokio::test] async fn test_delete_quote_success() { - let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let app = router(MockRepo::with_quote(sample_quote(), "correct"), None); let req = Request::builder() .method(Method::DELETE) .uri("/api/quotes/abc-123") @@ -1340,7 +1375,7 @@ mod tests { #[tokio::test] async fn test_delete_quote_not_found() { - let app = router(MockRepo::empty()); + let app = router(MockRepo::empty(), None); let req = Request::builder() .method(Method::DELETE) .uri("/api/quotes/nonexistent") @@ -1421,7 +1456,7 @@ mod tests { /// the repo's submissions lock is unset (the default `false` state). #[tokio::test] async fn test_get_status_unlocked() { - let app = router(MockRepo::empty()); + let app = router(MockRepo::empty(), None); let req = Request::builder() .method(Method::GET) .uri("/api/status") @@ -1441,7 +1476,7 @@ mod tests { repo.set_submissions_locked(true) .await .expect("set_submissions_locked should not fail"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::GET) .uri("/api/status") @@ -1472,7 +1507,7 @@ mod tests { #[tokio::test] async fn test_lock_submissions_correct_code_returns_200() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::POST) .uri("/api/admin/lock") @@ -1494,7 +1529,7 @@ mod tests { repo.set_submissions_locked(true) .await .expect("set_submissions_locked should not fail"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::POST) .uri("/api/admin/unlock") @@ -1511,7 +1546,7 @@ mod tests { #[tokio::test] async fn test_lock_submissions_wrong_code_returns_403() { let repo = MockRepo::with_admin_code("real-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::POST) .uri("/api/admin/lock") @@ -1526,7 +1561,7 @@ mod tests { #[tokio::test] async fn test_unlock_submissions_missing_header_returns_403() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::POST) .uri("/api/admin/unlock") @@ -1545,7 +1580,7 @@ mod tests { repo.set_submissions_locked(true) .await .expect("initial lock should not fail"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::POST) .uri("/api/admin/lock") @@ -1566,7 +1601,7 @@ mod tests { #[tokio::test] async fn test_reset_auth_code_correct_code_no_new_code_returns_200() { let repo = MockRepo::with_admin_code("current-secret"); - let app = router(repo); + let app = router(repo, None); let body = serde_json::json!({}); let req = Request::builder() .method(Method::POST) @@ -1593,7 +1628,7 @@ mod tests { #[tokio::test] async fn test_reset_auth_code_correct_code_explicit_new_code_returns_200() { let repo = MockRepo::with_admin_code("current-secret"); - let app = router(repo); + let app = router(repo, None); let body = serde_json::json!({ "new_code": "brand-new-passphrase" }); let req = Request::builder() .method(Method::POST) @@ -1615,7 +1650,7 @@ mod tests { #[tokio::test] async fn test_reset_auth_code_wrong_code_returns_403() { let repo = MockRepo::with_admin_code("real-secret"); - let app = router(repo); + let app = router(repo, None); let body = serde_json::json!({}); let req = Request::builder() .method(Method::POST) @@ -1632,7 +1667,7 @@ mod tests { #[tokio::test] async fn test_reset_auth_code_missing_header_returns_403() { let repo = MockRepo::with_admin_code("real-secret"); - let app = router(repo); + let app = router(repo, None); let body = serde_json::json!({}); let req = Request::builder() .method(Method::POST) @@ -1649,7 +1684,7 @@ mod tests { /// `POST /api/quotes/:id/report` with a known quote ID returns `201 Created`. #[tokio::test] async fn test_report_success() { - let app = router(MockRepo::with_quote(sample_quote(), "auth")); + let app = router(MockRepo::with_quote(sample_quote(), "auth"), None); let body = serde_json::json!({ "reason": "inappropriate content" }); let req = Request::builder() .method(Method::POST) @@ -1664,7 +1699,7 @@ mod tests { /// `POST /api/quotes/:id/report` with an unknown quote ID returns `404 Not Found`. #[tokio::test] async fn test_report_quote_not_found() { - let app = router(MockRepo::empty()); + let app = router(MockRepo::empty(), None); let body = serde_json::json!({}); let req = Request::builder() .method(Method::POST) @@ -1680,7 +1715,7 @@ mod tests { /// returns `400 Bad Request`. #[tokio::test] async fn test_report_reason_too_long() { - let app = router(MockRepo::with_quote(sample_quote(), "auth")); + let app = router(MockRepo::with_quote(sample_quote(), "auth"), None); let long_reason = "x".repeat(257); let body = serde_json::json!({ "reason": long_reason }); let req = Request::builder() @@ -1708,7 +1743,7 @@ mod tests { .header("Content-Type", "application/json") .body(Body::from(first_body.to_string())) .unwrap(); - let app = router(Arc::clone(&repo) as Repo); + let app = router(Arc::clone(&repo) as Repo, None); let (status, _) = send(app, first_req).await; assert_eq!(status, StatusCode::OK, "first reset must succeed"); @@ -1721,7 +1756,7 @@ mod tests { .header("Content-Type", "application/json") .body(Body::from(second_body.to_string())) .unwrap(); - let app2 = router(Arc::clone(&repo) as Repo); + let app2 = router(Arc::clone(&repo) as Repo, None); let (status2, _) = send(app2, second_req).await; assert_eq!( status2, @@ -1738,7 +1773,7 @@ mod tests { .header("Content-Type", "application/json") .body(Body::from(third_body.to_string())) .unwrap(); - let app3 = router(repo as Repo); + let app3 = router(repo as Repo, None); let (status3, resp_body3) = send(app3, third_req).await; assert_eq!( status3, @@ -1759,7 +1794,7 @@ mod tests { #[tokio::test] async fn test_list_reports_correct_code_returns_200() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::GET) .uri("/api/admin/reports") @@ -1777,7 +1812,7 @@ mod tests { #[tokio::test] async fn test_list_reports_missing_header_returns_403() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::GET) .uri("/api/admin/reports") @@ -1791,7 +1826,7 @@ mod tests { #[tokio::test] async fn test_list_reports_wrong_code_returns_403() { let repo = MockRepo::with_admin_code("real-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::GET) .uri("/api/admin/reports") @@ -1826,7 +1861,7 @@ mod tests { let quote_id = quotes[0].0.id.clone(); drop(quotes); - let app = router(Arc::clone(&repo) as Repo); + let app = router(Arc::clone(&repo) as Repo, None); let req = Request::builder() .method(Method::GET) .uri(format!("/api/admin/reports/{quote_id}")) @@ -1844,7 +1879,7 @@ mod tests { #[tokio::test] async fn test_get_quote_reports_not_found_returns_404() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::GET) .uri("/api/admin/reports/nonexistent") @@ -1859,7 +1894,7 @@ mod tests { #[tokio::test] async fn test_get_quote_reports_missing_header_returns_403() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::GET) .uri("/api/admin/reports/any-id") @@ -1891,7 +1926,7 @@ mod tests { let quote_id = quotes[0].0.id.clone(); drop(quotes); - let app = router(Arc::clone(&repo) as Repo); + let app = router(Arc::clone(&repo) as Repo, None); let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/admin/reports/{quote_id}/quote")) @@ -1911,7 +1946,7 @@ mod tests { #[tokio::test] async fn test_admin_delete_quote_not_found_returns_404() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::DELETE) .uri("/api/admin/reports/nonexistent/quote") @@ -1927,7 +1962,7 @@ mod tests { #[tokio::test] async fn test_admin_delete_quote_missing_header_returns_403() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::DELETE) .uri("/api/admin/reports/any-id/quote") @@ -1959,7 +1994,7 @@ mod tests { let quote_id = quotes[0].0.id.clone(); drop(quotes); - let app = router(Arc::clone(&repo) as Repo); + let app = router(Arc::clone(&repo) as Repo, None); let req = Request::builder() .method(Method::POST) .uri(format!("/api/admin/reports/{quote_id}/hide")) @@ -1981,7 +2016,7 @@ mod tests { #[tokio::test] async fn test_hide_quote_not_found_returns_404() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::POST) .uri("/api/admin/reports/nonexistent/hide") @@ -1997,7 +2032,7 @@ mod tests { #[tokio::test] async fn test_hide_quote_missing_header_returns_403() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::POST) .uri("/api/admin/reports/any-id/hide") @@ -2029,7 +2064,7 @@ mod tests { let quote_id = quotes[0].0.id.clone(); drop(quotes); - let app = router(Arc::clone(&repo) as Repo); + let app = router(Arc::clone(&repo) as Repo, None); let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/admin/reports/{quote_id}/reports")) @@ -2045,7 +2080,7 @@ mod tests { #[tokio::test] async fn test_clear_reports_not_found_returns_404() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::DELETE) .uri("/api/admin/reports/nonexistent/reports") @@ -2061,7 +2096,7 @@ mod tests { #[tokio::test] async fn test_clear_reports_missing_header_returns_403() { let repo = MockRepo::with_admin_code("admin-secret"); - let app = router(repo); + let app = router(repo, None); let req = Request::builder() .method(Method::DELETE) .uri("/api/admin/reports/any-id/reports") @@ -2070,6 +2105,42 @@ mod tests { let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::FORBIDDEN); } + + // ── ADMIN_AUTH_CODE secret override tests ────────────────────────────────── + + /// When `admin_secret` is `Some`, it takes precedence over the DB-stored code. + #[tokio::test] + async fn test_admin_secret_overrides_db_code() { + // DB has "db-code" but the secret is "secret-code". + let repo = MockRepo::with_admin_code("db-code"); + let app = router(repo, Some("secret-code".to_owned())); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/lock") + .header("X-Admin-Code", "secret-code") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + } + + /// When `admin_secret` is `Some`, `reset-auth-code` returns `409 Conflict`. + #[tokio::test] + async fn test_reset_auth_code_blocked_when_secret_active() { + let repo = MockRepo::with_admin_code("db-code"); + let app = router(repo, Some("secret-code".to_owned())); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("Content-Type", "application/json") + .header("X-Admin-Code", "secret-code") + .body(Body::from(r#"{"new_code": "anything"}"#)) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::CONFLICT); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(v["error"].as_str().unwrap().contains("ADMIN_AUTH_CODE")); + } } // ── Integration tests (real NativeRepository + real SQLite) ───────────────── @@ -2111,7 +2182,7 @@ mod integration_tests { .expect("failed to open test database"); repo.run_migrations().await.expect("migrations failed"); let repo: Arc = Arc::new(repo); - (router(repo), f) + (router(repo, None), f) } // ── Body helpers ────────────────────────────────────────────────────────── @@ -2940,7 +3011,7 @@ mod integration_tests { .await .expect("failed to seed submissions lock"); let repo: Arc = Arc::new(repo); - (router(repo), f) + (router(repo, None), f) } /// Submit a report for a quote via `POST /api/quotes/:id/report`. diff --git a/quotesdb/src/lib.rs b/quotesdb/src/lib.rs index 7d7b2d6..9744970 100644 --- a/quotesdb/src/lib.rs +++ b/quotesdb/src/lib.rs @@ -351,17 +351,23 @@ pub async fn fetch( .await .map_err(|e| worker::Error::RustError(e.to_string()))?; - // Seed admin auth code on first startup (no-op if already present). - if repo - .get_admin_auth_code() - .await - .map_err(|e| worker::Error::RustError(e.to_string()))? - .is_none() - { - let code = crate::generate_auth_code(); - repo.seed_admin_auth_code(&code) + // Read optional ADMIN_AUTH_CODE secret from Worker environment. + // When set, it overrides the DB-stored admin code — no DB read needed. + let admin_secret = env.secret("ADMIN_AUTH_CODE").ok().map(|s| s.to_string()); + + // Seed DB admin code only when no secret is configured and no code exists yet. + if admin_secret.is_none() { + if repo + .get_admin_auth_code() .await - .map_err(|e| worker::Error::RustError(e.to_string()))?; + .map_err(|e| worker::Error::RustError(e.to_string()))? + .is_none() + { + let code = crate::generate_auth_code(); + repo.seed_admin_auth_code(&code) + .await + .map_err(|e| worker::Error::RustError(e.to_string()))?; + } } // Seed submissions_locked = '0' on first startup (INSERT OR IGNORE — no-op @@ -375,7 +381,7 @@ pub async fn fetch( let repo: Arc = Arc::new(repo); // Build the Axum router with all routes registered. - let mut router = crate::handlers::router(repo); + let mut router = crate::handlers::router(repo, admin_secret); // Call the Axum router directly; the http feature ensures request/response // types are compatible with standard http crate types that Axum expects.