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