feat(quotesdb): enforce submission lock on PUT /api/quotes

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 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent 64cd35ce1e
commit 7619391d43

@ -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:

@ -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<Repo>, Json(input): Json<CreateQuoteInput>) -> 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"));

Loading…
Cancel
Save