From d693ce18cb6b9aad6b2e2aedc8fab5c2d12dd857 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Wed, 4 Mar 2026 13:14:36 -0800 Subject: [PATCH] feat(quotesdb): POST /api/admin/lock and /api/admin/unlock endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two admin-protected endpoints that toggle the global submissions lock: - POST /api/admin/lock — sets submissions_locked = true - POST /api/admin/unlock — sets submissions_locked = false Both require the X-Admin-Code header and return { "submissions_locked": bool } on success, or 403 on missing/wrong code. Operation is idempotent. Shared helper verify_admin_code() fetches and compares the stored admin code. Routes registered in the router() function. Five unit tests added covering correct code, wrong code, missing header, and idempotent lock behaviour. OpenAPI spec updated with AdminCode security scheme, LockResponse schema, /api/admin/lock and /api/admin/unlock path entries, and an admin tag. Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/api/openapi.yaml | 70 +++++++++++ quotesdb/src/bin/api/handlers/mod.rs | 167 +++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) diff --git a/quotesdb/api/openapi.yaml b/quotesdb/api/openapi.yaml index 9b28061..1c429f3 100644 --- a/quotesdb/api/openapi.yaml +++ b/quotesdb/api/openapi.yaml @@ -28,6 +28,13 @@ components: description: > 4-word passphrase returned when the quote was created (e.g. ocean-table-purple-storm). Required for update and delete. + AdminCode: + type: apiKey + in: header + name: X-Admin-Code + description: > + Super admin passphrase seeded at server startup and printed to the + server log. Required for all /api/admin/* endpoints. # ------------------------------------------------------------------------- # Schemas @@ -190,6 +197,17 @@ components: description: Whether new quote submissions are currently disabled. example: false + # Returned by POST /api/admin/lock and POST /api/admin/unlock. + LockResponse: + type: object + required: + - submissions_locked + properties: + submissions_locked: + type: boolean + description: The current lock state after the operation. + example: true + # Standard error envelope used by all error responses. Error: type: object @@ -247,6 +265,56 @@ paths: schema: $ref: "#/components/schemas/Error" + /api/admin/lock: + post: + operationId: lockSubmissions + summary: Lock new quote submissions + description: > + Sets submissions_locked to true, preventing any new quotes from being + created via PUT /api/quotes. Requires the X-Admin-Code header. + The operation is idempotent — locking when already locked returns 200. + tags: [admin] + security: + - AdminCode: [] + responses: + "200": + description: Submissions are now locked. + content: + application/json: + schema: + $ref: "#/components/schemas/LockResponse" + "403": + description: X-Admin-Code header missing or incorrect. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /api/admin/unlock: + post: + operationId: unlockSubmissions + summary: Unlock new quote submissions + description: > + Sets submissions_locked to false, re-enabling quote creation via + PUT /api/quotes. Requires the X-Admin-Code header. + The operation is idempotent — unlocking when already unlocked returns 200. + tags: [admin] + security: + - AdminCode: [] + responses: + "200": + description: Submissions are now unlocked. + content: + application/json: + schema: + $ref: "#/components/schemas/LockResponse" + "403": + description: X-Admin-Code header missing or incorrect. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/quotes: get: operationId: listQuotes @@ -477,3 +545,5 @@ tags: description: API metadata endpoints. - name: quotes description: CRUD operations on quotes. + - name: admin + description: Admin-only endpoints for managing the submissions lock. diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index c42c1bb..5c2cf3e 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -353,6 +353,29 @@ fn extract_auth_code(headers: &HeaderMap) -> Option { .map(|s| s.to_owned()) } +/// Extract the `X-Admin-Code` header value from the request headers. +/// +/// Returns `None` if the header is absent or cannot be decoded as UTF-8. +fn extract_admin_code(headers: &HeaderMap) -> Option { + headers + .get("X-Admin-Code") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()) +} + +/// Verify that the supplied admin code matches the one stored in the repository. +/// +/// Fetches the current admin code via [`QuoteRepository::get_admin_auth_code`] +/// and performs a constant-time-equivalent string comparison. 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 { + Ok(Some(stored)) => stored == code, + _ => false, + } +} + /// `POST /api/quotes/:id` — update an existing quote. /// /// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, @@ -396,6 +419,54 @@ async fn delete_handler( } } +/// `POST /api/admin/lock` — lock new quote submissions. +/// +/// Requires the `X-Admin-Code` header. Sets `submissions_locked = true` in +/// the repository and returns the updated lock state as JSON: +/// +/// ```json +/// { "submissions_locked": true } +/// ``` +/// +/// Returns `403 Forbidden` if the header is missing or the code is incorrect. +#[cfg_attr(target_arch = "wasm32", worker::send)] +pub async fn lock_submissions(State(repo): 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 { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.set_submissions_locked(true).await { + Ok(()) => Json(serde_json::json!({ "submissions_locked": true })).into_response(), + Err(e) => db_error_response(e), + } +} + +/// `POST /api/admin/unlock` — unlock new quote submissions. +/// +/// Requires the `X-Admin-Code` header. Sets `submissions_locked = false` in +/// the repository and returns the updated lock state as JSON: +/// +/// ```json +/// { "submissions_locked": false } +/// ``` +/// +/// Returns `403 Forbidden` if the header is missing or the code is incorrect. +#[cfg_attr(target_arch = "wasm32", worker::send)] +pub async fn unlock_submissions(State(repo): 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 { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.set_submissions_locked(false).await { + Ok(()) => Json(serde_json::json!({ "submissions_locked": false })).into_response(), + Err(e) => db_error_response(e), + } +} + // ── Router ──────────────────────────────────────────────────────────────────── /// Build the Axum [`Router`] with all API routes wired to their handlers. @@ -415,6 +486,9 @@ 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. + .route("/api/admin/lock", post(lock_submissions)) + .route("/api/admin/unlock", post(unlock_submissions)) // IMPORTANT: /random must be registered before /{id} so the static // segment wins over the dynamic capture. .route("/api/quotes/random", get(random_handler)) @@ -949,6 +1023,99 @@ mod tests { .expect("get_submissions_locked should not fail"); assert!(!locked, "submissions should default to unlocked"); } + + // ── POST /api/admin/lock handler tests ──────────────────────────────────── + + /// `POST /api/admin/lock` with the correct admin code returns `200` and + /// `{ "submissions_locked": true }`. + #[tokio::test] + async fn test_lock_submissions_correct_code_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/lock") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["submissions_locked"], true); + } + + /// `POST /api/admin/unlock` with the correct admin code returns `200` and + /// `{ "submissions_locked": false }`. + #[tokio::test] + async fn test_unlock_submissions_correct_code_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + // Start in locked state. + repo.set_submissions_locked(true) + .await + .expect("set_submissions_locked should not fail"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/unlock") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["submissions_locked"], false); + } + + /// `POST /api/admin/lock` with a wrong admin code returns `403`. + #[tokio::test] + async fn test_lock_submissions_wrong_code_returns_403() { + let repo = MockRepo::with_admin_code("real-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/lock") + .header("X-Admin-Code", "wrong-code") + .body(Body::empty()) + .unwrap(); + let (status, _body) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// `POST /api/admin/unlock` with no `X-Admin-Code` header returns `403`. + #[tokio::test] + async fn test_unlock_submissions_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/unlock") + .body(Body::empty()) + .unwrap(); + let (status, _body) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// Locking when already locked is idempotent — returns `200` with + /// `{ "submissions_locked": true }`. + #[tokio::test] + async fn test_lock_submissions_idempotent() { + let repo = MockRepo::with_admin_code("admin-secret"); + // Lock once via the trait directly. + repo.set_submissions_locked(true) + .await + .expect("initial lock should not fail"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/lock") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["submissions_locked"], true); + } } // ── Integration tests (real NativeRepository + real SQLite) ─────────────────