@ -483,6 +483,59 @@ async fn unlock_submissions(State(repo): State<Repo>, headers: HeaderMap) -> Res
}
}
/// Request body for `POST /api/admin/reset-auth-code`.
#[ derive(Debug, Deserialize) ]
struct ResetAuthCodeRequest {
/// New admin auth code. If omitted, the server generates a fresh 4-word passphrase.
new_code : Option < String > ,
}
/// Response body returned by `POST /api/admin/reset-auth-code`.
#[ derive(Debug, Serialize) ]
struct ResetAuthCodeResponse {
/// The new admin auth code that is now in effect.
auth_code : String ,
}
/// `POST /api/admin/reset-auth-code` — replace the stored admin auth code.
///
/// Requires the `X-Admin-Code` header containing the **current** admin
/// passphrase. If the header matches the stored code, the code is replaced
/// with either the supplied `new_code` value or a freshly generated 4-word
/// passphrase when `new_code` is omitted.
///
/// The new code is returned in the response body:
///
/// ```json
/// { "auth_code": "word-word-word-word" }
/// ```
///
/// Returns `403 Forbidden` if the header is absent or the code is incorrect.
/// The DB layer (`update_admin_auth_code`) performs the auth check internally
/// and returns `DbError::Forbidden` on mismatch.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn reset_auth_code (
State ( repo ) : State < Repo > ,
headers : HeaderMap ,
Json ( payload ) : Json < ResetAuthCodeRequest > ,
) -> Response {
let admin_code = match extract_admin_code ( & headers ) {
Some ( c ) = > c ,
None = > return StatusCode ::FORBIDDEN . into_response ( ) ,
} ;
match repo
. update_admin_auth_code ( & admin_code , payload . new_code . as_deref ( ) )
. await
{
Ok ( new_code ) = > Json ( ResetAuthCodeResponse {
auth_code : new_code ,
} )
. into_response ( ) ,
Err ( crate ::db ::DbError ::Forbidden ) = > StatusCode ::FORBIDDEN . into_response ( ) ,
Err ( _ ) = > StatusCode ::INTERNAL_SERVER_ERROR . into_response ( ) ,
}
}
// ── Router ────────────────────────────────────────────────────────────────────
/// Build the Axum [`Router`] with all API routes wired to their handlers.
@ -502,9 +555,10 @@ pub fn router(repo: Arc<dyn QuoteRepository + Send + Sync>) -> Router {
. route ( "/api/" , get ( openapi_handler ) )
// Public status — exposes whether submissions are currently locked.
. route ( "/api/status" , get ( get_status ) )
// Admin endpoints — toggle the global submissions lock .
// Admin endpoints — toggle the global submissions lock and reset auth code .
. route ( "/api/admin/lock" , post ( lock_submissions ) )
. route ( "/api/admin/unlock" , post ( unlock_submissions ) )
. route ( "/api/admin/reset-auth-code" , post ( reset_auth_code ) )
// IMPORTANT: /random must be registered before /{id} so the static
// segment wins over the dynamic capture.
. route ( "/api/quotes/random" , get ( random_handler ) )
@ -1214,6 +1268,151 @@ mod tests {
let v : serde_json ::Value = serde_json ::from_str ( & body ) . unwrap ( ) ;
assert_eq! ( v [ "submissions_locked" ] , true ) ;
}
// ── POST /api/admin/reset-auth-code handler tests ─────────────────────────
/// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no
/// `new_code` in the body returns `200` with a non-empty `auth_code`.
/// The MockRepo returns `"new-mock-code"` when `new_code` is `None`.
#[ tokio::test ]
async fn test_reset_auth_code_correct_code_no_new_code_returns_200 ( ) {
let repo = MockRepo ::with_admin_code ( "current-secret" ) ;
let app = router ( repo ) ;
let body = serde_json ::json ! ( { } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reset-auth-code" )
. header ( "X-Admin-Code" , "current-secret" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( body . to_string ( ) ) )
. unwrap ( ) ;
let ( status , resp_body ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::OK ) ;
let v : serde_json ::Value = serde_json ::from_str ( & resp_body ) . unwrap ( ) ;
assert! (
v [ "auth_code" ] . is_string ( ) ,
"response must contain auth_code string"
) ;
assert! (
! v [ "auth_code" ] . as_str ( ) . unwrap ( ) . is_empty ( ) ,
"auth_code must be non-empty"
) ;
}
/// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and an
/// explicit `new_code` returns `200` and `auth_code` equals the supplied value.
#[ tokio::test ]
async fn test_reset_auth_code_correct_code_explicit_new_code_returns_200 ( ) {
let repo = MockRepo ::with_admin_code ( "current-secret" ) ;
let app = router ( repo ) ;
let body = serde_json ::json ! ( { "new_code" : "brand-new-passphrase" } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reset-auth-code" )
. header ( "X-Admin-Code" , "current-secret" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( body . to_string ( ) ) )
. unwrap ( ) ;
let ( status , resp_body ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::OK ) ;
let v : serde_json ::Value = serde_json ::from_str ( & resp_body ) . unwrap ( ) ;
assert_eq! (
v [ "auth_code" ] , "brand-new-passphrase" ,
"auth_code must equal the supplied new_code"
) ;
}
/// `POST /api/admin/reset-auth-code` with a wrong `X-Admin-Code` returns `403`.
#[ tokio::test ]
async fn test_reset_auth_code_wrong_code_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let app = router ( repo ) ;
let body = serde_json ::json ! ( { } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reset-auth-code" )
. header ( "X-Admin-Code" , "wrong-secret" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( body . to_string ( ) ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
/// `POST /api/admin/reset-auth-code` with no `X-Admin-Code` header returns `403`.
#[ tokio::test ]
async fn test_reset_auth_code_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let app = router ( repo ) ;
let body = serde_json ::json ! ( { } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reset-auth-code" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( body . to_string ( ) ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
/// After a successful reset, subsequent calls with the old code return `403`
/// and with the new code return `200`.
#[ tokio::test ]
async fn test_reset_auth_code_old_code_rejected_after_reset ( ) {
let repo = MockRepo ::with_admin_code ( "old-secret" ) ;
// First reset: change from "old-secret" to "new-secret".
let first_body = serde_json ::json ! ( { "new_code" : "new-secret" } ) ;
let first_req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reset-auth-code" )
. header ( "X-Admin-Code" , "old-secret" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( first_body . to_string ( ) ) )
. unwrap ( ) ;
let app = router ( Arc ::clone ( & repo ) as Repo ) ;
let ( status , _ ) = send ( app , first_req ) . await ;
assert_eq! ( status , StatusCode ::OK , "first reset must succeed" ) ;
// Second call with old code must now be forbidden.
let second_body = serde_json ::json ! ( { } ) ;
let second_req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reset-auth-code" )
. header ( "X-Admin-Code" , "old-secret" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( second_body . to_string ( ) ) )
. unwrap ( ) ;
let app2 = router ( Arc ::clone ( & repo ) as Repo ) ;
let ( status2 , _ ) = send ( app2 , second_req ) . await ;
assert_eq! (
status2 ,
StatusCode ::FORBIDDEN ,
"old code must be rejected after reset"
) ;
// Third call with the new code must succeed.
let third_body = serde_json ::json ! ( { } ) ;
let third_req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reset-auth-code" )
. header ( "X-Admin-Code" , "new-secret" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( third_body . to_string ( ) ) )
. unwrap ( ) ;
let app3 = router ( repo as Repo ) ;
let ( status3 , resp_body3 ) = send ( app3 , third_req ) . await ;
assert_eq! (
status3 ,
StatusCode ::OK ,
"new code must be accepted after reset"
) ;
let v : serde_json ::Value = serde_json ::from_str ( & resp_body3 ) . unwrap ( ) ;
assert! (
v [ "auth_code" ] . is_string ( ) ,
"response must include auth_code after second reset"
) ;
}
}
// ── Integration tests (real NativeRepository + real SQLite) ─────────────────