@ -309,11 +309,27 @@ async fn verify_turnstile(token: &str, secret: &str) -> bool {
/// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only
/// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only
/// time it is returned — the client must store it.
/// 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
/// When the `TURNSTILE_SECRET_KEY` environment variable is set, a valid
/// Cloudflare Turnstile token must be provided in the `cf_turnstile_token`
/// Cloudflare Turnstile token must be provided in the `cf_turnstile_token`
/// field. This check is skipped on wasm32 targets (Workers runtime).
/// field. This check is skipped on wasm32 targets (Workers runtime).
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn create_handler ( State ( repo ) : State < Repo > , Json ( input ) : Json < CreateQuoteInput > ) -> Response {
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).
// Verify Cloudflare Turnstile token (native builds only; skipped on wasm32).
#[ cfg(not(target_arch = " wasm32 " )) ]
#[ cfg(not(target_arch = " wasm32 " )) ]
{
{
@ -552,6 +568,15 @@ mod tests {
submissions_locked : std ::sync ::Mutex ::new ( false ) ,
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 ]
#[ async_trait::async_trait ]
@ -826,6 +851,79 @@ mod tests {
assert_eq! ( v [ "quote" ] [ "text" ] , "New quote" ) ;
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 ]
#[ tokio::test ]
async fn test_update_quote_missing_auth ( ) {
async fn test_update_quote_missing_auth ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) ) ;
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) ) ;