From 7619391d43370be2cfe6d876c2a45197d695203c Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Wed, 4 Mar 2026 20:35:14 -0800 Subject: [PATCH] feat(quotesdb): enforce submission lock on PUT /api/quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pre-flight check at the top of create_handler that calls get_submissions_locked() before processing the request. Returns 423 Locked with {"error": "submissions are closed"} when locked. Update openapi.yaml to document the 423 response on PUT /api/quotes. Add three unit tests: locked → 423, unlocked → 201, unlock-then-create → 201. Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/api/openapi.yaml | 6 ++ quotesdb/src/bin/api/handlers/mod.rs | 98 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/quotesdb/api/openapi.yaml b/quotesdb/api/openapi.yaml index cbf1ac2..f271149 100644 --- a/quotesdb/api/openapi.yaml +++ b/quotesdb/api/openapi.yaml @@ -437,6 +437,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "423": + description: Submissions are currently locked. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" # NOTE: registered before /api/quotes/{id} in the Rust router. /api/quotes/random: diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index 03a2d96..47df00b 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -309,11 +309,27 @@ async fn verify_turnstile(token: &str, secret: &str) -> bool { /// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only /// time it is returned — the client must store it. /// +/// Returns `423 Locked` with `{"error": "submissions are closed"}` when the +/// admin has locked new submissions via `POST /api/admin/lock`. +/// /// When the `TURNSTILE_SECRET_KEY` environment variable is set, a valid /// Cloudflare Turnstile token must be provided in the `cf_turnstile_token` /// field. This check is skipped on wasm32 targets (Workers runtime). #[cfg_attr(target_arch = "wasm32", worker::send)] async fn create_handler(State(repo): State, Json(input): Json) -> Response { + // Pre-flight: reject new submissions when locked. + match repo.get_submissions_locked().await { + Ok(true) => { + return ( + StatusCode::LOCKED, + Json(serde_json::json!({ "error": "submissions are closed" })), + ) + .into_response(); + } + Ok(false) => {} + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + // Verify Cloudflare Turnstile token (native builds only; skipped on wasm32). #[cfg(not(target_arch = "wasm32"))] { @@ -552,6 +568,15 @@ mod tests { submissions_locked: std::sync::Mutex::new(false), }) } + + /// Build a [`Repo`] with submissions locked to the given state. + fn with_submissions_locked(locked: bool) -> Repo { + Arc::new(Self { + quotes: std::sync::Mutex::new(vec![]), + admin_auth_code: std::sync::Mutex::new(None), + submissions_locked: std::sync::Mutex::new(locked), + }) + } } #[async_trait::async_trait] @@ -826,6 +851,79 @@ mod tests { assert_eq!(v["quote"]["text"], "New quote"); } + /// `PUT /api/quotes` while `submissions_locked = true` returns `423 Locked` + /// with `{"error": "submissions are closed"}`. + #[tokio::test] + async fn test_create_quote_locked_returns_423() { + let app = router(MockRepo::with_submissions_locked(true)); + let body = serde_json::json!({ + "text": "Locked quote", + "author": "Author", + "tags": [] + }); + let req = Request::builder() + .method(Method::PUT) + .uri("/api/quotes") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::LOCKED); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert_eq!(v["error"], "submissions are closed"); + } + + /// `PUT /api/quotes` while `submissions_locked = false` returns `201 Created` + /// (existing behaviour is unchanged). + #[tokio::test] + async fn test_create_quote_unlocked_returns_201() { + let app = router(MockRepo::with_submissions_locked(false)); + let body = serde_json::json!({ + "text": "Unlocked quote", + "author": "Author", + "tags": [] + }); + let req = Request::builder() + .method(Method::PUT) + .uri("/api/quotes") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::CREATED); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert!(v["auth_code"].is_string()); + } + + /// After unlocking (`submissions_locked = false` after being `true`), + /// `PUT /api/quotes` succeeds again with `201 Created`. + #[tokio::test] + async fn test_create_quote_after_unlock_returns_201() { + // Build a repo that starts locked. + let repo = MockRepo::with_submissions_locked(true); + // Unlock it. + repo.set_submissions_locked(false) + .await + .expect("set_submissions_locked should not fail"); + let app = router(repo); + let body = serde_json::json!({ + "text": "Re-enabled quote", + "author": "Author", + "tags": [] + }); + let req = Request::builder() + .method(Method::PUT) + .uri("/api/quotes") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::CREATED); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert!(v["auth_code"].is_string()); + assert_eq!(v["quote"]["text"], "Re-enabled quote"); + } + #[tokio::test] async fn test_update_quote_missing_auth() { let app = router(MockRepo::with_quote(sample_quote(), "correct"));