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) ─────────────────