From 8b301d23bbee38f31c872a72034da67cefe08404 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Wed, 4 Mar 2026 20:42:01 -0800 Subject: [PATCH] feat(quotesdb): POST /api/admin/reset-auth-code endpoint Adds handler, route registration, request/response types, and five unit tests for the admin auth-code rotation endpoint. Updates openapi.yaml with the new path and a ResetAuthCodeResponse component schema. Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/api/openapi.yaml | 57 ++++++++ quotesdb/src/bin/api/handlers/mod.rs | 201 ++++++++++++++++++++++++++- 2 files changed, 257 insertions(+), 1 deletion(-) diff --git a/quotesdb/api/openapi.yaml b/quotesdb/api/openapi.yaml index f271149..d40df98 100644 --- a/quotesdb/api/openapi.yaml +++ b/quotesdb/api/openapi.yaml @@ -208,6 +208,17 @@ components: description: The current lock state after the operation. example: true + # Returned by POST /api/admin/reset-auth-code. + ResetAuthCodeResponse: + type: object + required: + - auth_code + properties: + auth_code: + type: string + description: The new admin auth code now in effect. + example: "ocean-table-purple-storm" + # Standard error envelope used by all error responses. Error: type: object @@ -327,6 +338,52 @@ paths: schema: $ref: "#/components/schemas/Error" + /api/admin/reset-auth-code: + post: + operationId: resetAdminAuthCode + summary: Replace the admin auth code + description: > + Replaces the stored admin super auth code with a new value. + The caller must supply the current code via the X-Admin-Code header. + If new_code is omitted from the request body, the server generates a + fresh 4-word passphrase. The new code is returned in the response body + and is immediately active — the old code is no longer accepted. + tags: [admin] + security: + - AdminCode: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + new_code: + type: string + description: > + Optional replacement code. If omitted, the server auto-generates + a 4-word passphrase (e.g. "ocean-table-purple-storm"). + example: "ocean-table-purple-storm" + responses: + "200": + description: The admin auth code was replaced successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/ResetAuthCodeResponse" + "403": + description: X-Admin-Code header missing or incorrect. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal server error. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/quotes: get: operationId: listQuotes diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index 47df00b..91088f5 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -483,6 +483,59 @@ async fn unlock_submissions(State(repo): State, headers: HeaderMap) -> Res } } +/// Request body for `POST /api/admin/reset-auth-code`. +#[derive(Debug, Deserialize)] +struct ResetAuthCodeRequest { + /// New admin auth code. If omitted, the server generates a fresh 4-word passphrase. + new_code: Option, +} + +/// Response body returned by `POST /api/admin/reset-auth-code`. +#[derive(Debug, Serialize)] +struct ResetAuthCodeResponse { + /// The new admin auth code that is now in effect. + auth_code: String, +} + +/// `POST /api/admin/reset-auth-code` — replace the stored admin auth code. +/// +/// Requires the `X-Admin-Code` header containing the **current** admin +/// passphrase. If the header matches the stored code, the code is replaced +/// with either the supplied `new_code` value or a freshly generated 4-word +/// passphrase when `new_code` is omitted. +/// +/// The new code is returned in the response body: +/// +/// ```json +/// { "auth_code": "word-word-word-word" } +/// ``` +/// +/// Returns `403 Forbidden` if the header is absent or the code is incorrect. +/// The DB layer (`update_admin_auth_code`) performs the auth check internally +/// and returns `DbError::Forbidden` on mismatch. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn reset_auth_code( + State(repo): State, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + let admin_code = match extract_admin_code(&headers) { + Some(c) => c, + None => return StatusCode::FORBIDDEN.into_response(), + }; + match repo + .update_admin_auth_code(&admin_code, payload.new_code.as_deref()) + .await + { + Ok(new_code) => Json(ResetAuthCodeResponse { + auth_code: new_code, + }) + .into_response(), + Err(crate::db::DbError::Forbidden) => StatusCode::FORBIDDEN.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + // ── Router ──────────────────────────────────────────────────────────────────── /// Build the Axum [`Router`] with all API routes wired to their handlers. @@ -502,9 +555,10 @@ pub fn router(repo: Arc) -> Router { .route("/api/", get(openapi_handler)) // Public status — exposes whether submissions are currently locked. .route("/api/status", get(get_status)) - // Admin endpoints — toggle the global submissions lock. + // Admin endpoints — toggle the global submissions lock and reset auth code. .route("/api/admin/lock", post(lock_submissions)) .route("/api/admin/unlock", post(unlock_submissions)) + .route("/api/admin/reset-auth-code", post(reset_auth_code)) // IMPORTANT: /random must be registered before /{id} so the static // segment wins over the dynamic capture. .route("/api/quotes/random", get(random_handler)) @@ -1214,6 +1268,151 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!(v["submissions_locked"], true); } + + // ── POST /api/admin/reset-auth-code handler tests ───────────────────────── + + /// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no + /// `new_code` in the body returns `200` with a non-empty `auth_code`. + /// The MockRepo returns `"new-mock-code"` when `new_code` is `None`. + #[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 body = serde_json::json!({}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "current-secret") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert!( + v["auth_code"].is_string(), + "response must contain auth_code string" + ); + assert!( + !v["auth_code"].as_str().unwrap().is_empty(), + "auth_code must be non-empty" + ); + } + + /// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and an + /// explicit `new_code` returns `200` and `auth_code` equals the supplied value. + #[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 body = serde_json::json!({ "new_code": "brand-new-passphrase" }); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "current-secret") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert_eq!( + v["auth_code"], "brand-new-passphrase", + "auth_code must equal the supplied new_code" + ); + } + + /// `POST /api/admin/reset-auth-code` with a wrong `X-Admin-Code` returns `403`. + #[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 body = serde_json::json!({}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "wrong-secret") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// `POST /api/admin/reset-auth-code` with no `X-Admin-Code` header returns `403`. + #[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 body = serde_json::json!({}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// After a successful reset, subsequent calls with the old code return `403` + /// and with the new code return `200`. + #[tokio::test] + async fn test_reset_auth_code_old_code_rejected_after_reset() { + let repo = MockRepo::with_admin_code("old-secret"); + + // First reset: change from "old-secret" to "new-secret". + let first_body = serde_json::json!({ "new_code": "new-secret" }); + let first_req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "old-secret") + .header("Content-Type", "application/json") + .body(Body::from(first_body.to_string())) + .unwrap(); + let app = router(Arc::clone(&repo) as Repo); + let (status, _) = send(app, first_req).await; + assert_eq!(status, StatusCode::OK, "first reset must succeed"); + + // Second call with old code must now be forbidden. + let second_body = serde_json::json!({}); + let second_req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "old-secret") + .header("Content-Type", "application/json") + .body(Body::from(second_body.to_string())) + .unwrap(); + let app2 = router(Arc::clone(&repo) as Repo); + let (status2, _) = send(app2, second_req).await; + assert_eq!( + status2, + StatusCode::FORBIDDEN, + "old code must be rejected after reset" + ); + + // Third call with the new code must succeed. + let third_body = serde_json::json!({}); + let third_req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "new-secret") + .header("Content-Type", "application/json") + .body(Body::from(third_body.to_string())) + .unwrap(); + let app3 = router(repo as Repo); + let (status3, resp_body3) = send(app3, third_req).await; + assert_eq!( + status3, + StatusCode::OK, + "new code must be accepted after reset" + ); + let v: serde_json::Value = serde_json::from_str(&resp_body3).unwrap(); + assert!( + v["auth_code"].is_string(), + "response must include auth_code after second reset" + ); + } } // ── Integration tests (real NativeRepository + real SQLite) ─────────────────