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 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 80b998c192
commit ab398b690c

@ -208,6 +208,17 @@ components:
description: The current lock state after the operation. description: The current lock state after the operation.
example: true 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. # Standard error envelope used by all error responses.
Error: Error:
type: object type: object
@ -327,6 +338,52 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $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: /api/quotes:
get: get:
operationId: listQuotes operationId: listQuotes

@ -483,6 +483,59 @@ async fn unlock_submissions(State(repo): State<Repo>, 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<String>,
}
/// 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<Repo>,
headers: HeaderMap,
Json(payload): Json<ResetAuthCodeRequest>,
) -> 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 ──────────────────────────────────────────────────────────────────── // ── Router ────────────────────────────────────────────────────────────────────
/// Build the Axum [`Router`] with all API routes wired to their handlers. /// Build the Axum [`Router`] with all API routes wired to their handlers.
@ -502,9 +555,10 @@ pub fn router(repo: Arc<dyn QuoteRepository + Send + Sync>) -> Router {
.route("/api/", get(openapi_handler)) .route("/api/", get(openapi_handler))
// Public status — exposes whether submissions are currently locked. // Public status — exposes whether submissions are currently locked.
.route("/api/status", get(get_status)) .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/lock", post(lock_submissions))
.route("/api/admin/unlock", post(unlock_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 // IMPORTANT: /random must be registered before /{id} so the static
// segment wins over the dynamic capture. // segment wins over the dynamic capture.
.route("/api/quotes/random", get(random_handler)) .route("/api/quotes/random", get(random_handler))
@ -1214,6 +1268,151 @@ mod tests {
let v: serde_json::Value = serde_json::from_str(&body).unwrap(); let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["submissions_locked"], true); 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) ───────────────── // ── Integration tests (real NativeRepository + real SQLite) ─────────────────

Loading…
Cancel
Save