feat(quotesdb): POST /api/admin/lock and /api/admin/unlock endpoints

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 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent f6f652ef3e
commit 401a4f45a5

@ -28,6 +28,13 @@ components:
description: > description: >
4-word passphrase returned when the quote was created 4-word passphrase returned when the quote was created
(e.g. ocean-table-purple-storm). Required for update and delete. (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 # Schemas
@ -190,6 +197,17 @@ components:
description: Whether new quote submissions are currently disabled. description: Whether new quote submissions are currently disabled.
example: false 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. # Standard error envelope used by all error responses.
Error: Error:
type: object type: object
@ -247,6 +265,56 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $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: /api/quotes:
get: get:
operationId: listQuotes operationId: listQuotes
@ -477,3 +545,5 @@ tags:
description: API metadata endpoints. description: API metadata endpoints.
- name: quotes - name: quotes
description: CRUD operations on quotes. description: CRUD operations on quotes.
- name: admin
description: Admin-only endpoints for managing the submissions lock.

@ -353,6 +353,29 @@ fn extract_auth_code(headers: &HeaderMap) -> Option<String> {
.map(|s| s.to_owned()) .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<String> {
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. /// `POST /api/quotes/:id` — update an existing quote.
/// ///
/// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, /// 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<Repo>, 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<Repo>, 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 ──────────────────────────────────────────────────────────────────── // ── 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.
@ -415,6 +486,9 @@ 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.
.route("/api/admin/lock", post(lock_submissions))
.route("/api/admin/unlock", post(unlock_submissions))
// 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))
@ -949,6 +1023,99 @@ mod tests {
.expect("get_submissions_locked should not fail"); .expect("get_submissions_locked should not fail");
assert!(!locked, "submissions should default to unlocked"); 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) ───────────────── // ── Integration tests (real NativeRepository + real SQLite) ─────────────────

Loading…
Cancel
Save