@ -31,6 +31,19 @@ use crate::db::{DeleteResult, QuoteRepository};
/// shared across Tokio tasks. `NativeRepository` satisfies both bounds.
/// shared across Tokio tasks. `NativeRepository` satisfies both bounds.
type Repo = Arc < dyn QuoteRepository + Send + Sync > ;
type Repo = Arc < dyn QuoteRepository + Send + Sync > ;
/// Shared application state threaded through all Axum handlers.
///
/// `admin_secret` is `Some` when an `ADMIN_AUTH_CODE` Cloudflare Worker secret
/// is configured. In that case it takes precedence over the DB-stored code and
/// the `reset-auth-code` endpoint is disabled (returning 409).
#[ derive(Clone) ]
pub struct AppState {
repo : Repo ,
/// Optional admin secret injected from the Worker environment.
/// When `Some`, this value is the sole source of truth for admin auth.
admin_secret : Option < String > ,
}
// ── Error response helpers ─────────────────────────────────────────────────────
// ── Error response helpers ─────────────────────────────────────────────────────
/// JSON envelope for all API error responses.
/// JSON envelope for all API error responses.
@ -171,8 +184,8 @@ async fn openapi_handler() -> Response {
///
///
/// Returns `500 Internal Server Error` if the database query fails.
/// Returns `500 Internal Server Error` if the database query fails.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
pub async fn get_status ( State ( repo) : State < Repo > ) -> Response {
pub async fn get_status ( State ( state) : State < AppState > ) -> Response {
match repo. get_submissions_locked ( ) . await {
match state. repo. get_submissions_locked ( ) . await {
Ok ( locked ) = > Json ( serde_json ::json ! ( { "submissions_locked" : locked } ) ) . into_response ( ) ,
Ok ( locked ) = > Json ( serde_json ::json ! ( { "submissions_locked" : locked } ) ) . into_response ( ) ,
Err ( _ ) = > StatusCode ::INTERNAL_SERVER_ERROR . into_response ( ) ,
Err ( _ ) = > StatusCode ::INTERNAL_SERVER_ERROR . into_response ( ) ,
}
}
@ -187,7 +200,8 @@ pub async fn get_status(State(repo): State<Repo>) -> Response {
/// Returns `400 Bad Request` when date component ordering is violated (e.g.
/// Returns `400 Bad Request` when date component ordering is violated (e.g.
/// `date_after_month` provided without `date_after_year`).
/// `date_after_month` provided without `date_after_year`).
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn list_handler ( State ( repo ) : State < Repo > , Query ( params ) : Query < ListParams > ) -> Response {
async fn list_handler ( State ( state ) : State < AppState > , Query ( params ) : Query < ListParams > ) -> Response {
let repo = & state . repo ;
// Validate: month requires year, day requires year+month
// Validate: month requires year, day requires year+month
if params . date_after_month . is_some ( ) & & params . date_after_year . is_none ( ) {
if params . date_after_month . is_some ( ) & & params . date_after_year . is_none ( ) {
return error_response (
return error_response (
@ -254,8 +268,8 @@ async fn list_handler(State(repo): State<Repo>, Query(params): Query<ListParams>
/// `GET /api/quotes/:id` in the router to avoid "random" being matched as an
/// `GET /api/quotes/:id` in the router to avoid "random" being matched as an
/// id parameter.
/// id parameter.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn random_handler ( State ( repo) : State < Repo > ) -> Response {
async fn random_handler ( State ( state) : State < AppState > ) -> Response {
match repo. get_random_quote ( ) . await {
match state. repo. get_random_quote ( ) . await {
Ok ( Some ( quote ) ) = > ( StatusCode ::OK , Json ( quote ) ) . into_response ( ) ,
Ok ( Some ( quote ) ) = > ( StatusCode ::OK , Json ( quote ) ) . into_response ( ) ,
Ok ( None ) = > error_response ( StatusCode ::NOT_FOUND , "no quotes in database" ) ,
Ok ( None ) = > error_response ( StatusCode ::NOT_FOUND , "no quotes in database" ) ,
Err ( e ) = > db_error_response ( e ) ,
Err ( e ) = > db_error_response ( e ) ,
@ -266,8 +280,8 @@ async fn random_handler(State(repo): State<Repo>) -> Response {
///
///
/// Returns `404` when no quote has the given id.
/// Returns `404` when no quote has the given id.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn get_quote_handler ( State ( repo) : State < Repo > , Path ( id ) : Path < String > ) -> Response {
async fn get_quote_handler ( State ( state) : State < AppState > , Path ( id ) : Path < String > ) -> Response {
match repo. get_quote ( & id ) . await {
match state. repo. get_quote ( & id ) . await {
Ok ( Some ( quote ) ) = > ( StatusCode ::OK , Json ( quote ) ) . into_response ( ) ,
Ok ( Some ( quote ) ) = > ( StatusCode ::OK , Json ( quote ) ) . into_response ( ) ,
Ok ( None ) = > error_response ( StatusCode ::NOT_FOUND , "quote not found" ) ,
Ok ( None ) = > error_response ( StatusCode ::NOT_FOUND , "quote not found" ) ,
Err ( e ) = > db_error_response ( e ) ,
Err ( e ) = > db_error_response ( e ) ,
@ -316,9 +330,12 @@ async fn verify_turnstile(token: &str, secret: &str) -> bool {
/// 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 ( state ) : State < AppState > ,
Json ( input ) : Json < CreateQuoteInput > ,
) -> Response {
// Pre-flight: reject new submissions when locked.
// Pre-flight: reject new submissions when locked.
match repo . get_submissions_locked ( ) . await {
match state. repo. get_submissions_locked ( ) . await {
Ok ( true ) = > {
Ok ( true ) = > {
return (
return (
StatusCode ::LOCKED ,
StatusCode ::LOCKED ,
@ -349,7 +366,7 @@ async fn create_handler(State(repo): State<Repo>, Json(input): Json<CreateQuoteI
}
}
}
}
match repo. create_quote ( input ) . await {
match state. repo. create_quote ( input ) . await {
Ok ( ( quote , auth_code ) ) = > (
Ok ( ( quote , auth_code ) ) = > (
StatusCode ::CREATED ,
StatusCode ::CREATED ,
Json ( CreateResponse { quote , auth_code } ) ,
Json ( CreateResponse { quote , auth_code } ) ,
@ -379,14 +396,19 @@ fn extract_admin_code(headers: &HeaderMap) -> Option<String> {
. map ( | s | s . to_owned ( ) )
. map ( | s | s . to_owned ( ) )
}
}
/// Verify that the supplied admin code matches the one stored in the repositor y.
/// Verify that the supplied admin code matches the configured authorit y.
///
///
/// Fetches the current admin code via [`QuoteRepository::get_admin_auth_code`]
/// When `state.admin_secret` is `Some`, the secret is compared directly and
/// and compares it with the supplied code using standard string equality.
/// the database is never queried. When it is `None`, the stored admin code is
/// fetched via [`QuoteRepository::get_admin_auth_code`] and compared with the
/// supplied code using standard string equality.
/// Returns `true` if the codes match, `false` if the code is wrong, missing,
/// Returns `true` if the codes match, `false` if the code is wrong, missing,
/// or the database query fails.
/// or the database query fails.
async fn verify_admin_code ( repo : & Repo , code : & str ) -> bool {
async fn verify_admin_code ( state : & AppState , code : & str ) -> bool {
match repo . get_admin_auth_code ( ) . await {
if let Some ( secret ) = & state . admin_secret {
return secret = = code ;
}
match state . repo . get_admin_auth_code ( ) . await {
Ok ( Some ( stored ) ) = > stored = = code ,
Ok ( Some ( stored ) ) = > stored = = code ,
_ = > false ,
_ = > false ,
}
}
@ -398,7 +420,7 @@ async fn verify_admin_code(repo: &Repo, code: &str) -> bool {
/// `404` if the quote does not exist, or `200` with the updated quote.
/// `404` if the quote does not exist, or `200` with the updated quote.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn update_handler (
async fn update_handler (
State ( repo) : State < Repo > ,
State ( state) : State < AppState > ,
Path ( id ) : Path < String > ,
Path ( id ) : Path < String > ,
headers : HeaderMap ,
headers : HeaderMap ,
Json ( input ) : Json < UpdateQuoteInput > ,
Json ( input ) : Json < UpdateQuoteInput > ,
@ -407,7 +429,7 @@ async fn update_handler(
return error_response ( StatusCode ::FORBIDDEN , "X-Auth-Code header is required" ) ;
return error_response ( StatusCode ::FORBIDDEN , "X-Auth-Code header is required" ) ;
} ;
} ;
match repo. update_quote ( & id , input , & auth_code ) . await {
match state. repo. update_quote ( & id , input , & auth_code ) . await {
Ok ( quote ) = > ( StatusCode ::OK , Json ( quote ) ) . into_response ( ) ,
Ok ( quote ) = > ( StatusCode ::OK , Json ( quote ) ) . into_response ( ) ,
Err ( e ) = > db_error_response ( e ) ,
Err ( e ) = > db_error_response ( e ) ,
}
}
@ -419,7 +441,7 @@ async fn update_handler(
/// `404` if not found, or `204 No Content` on success.
/// `404` if not found, or `204 No Content` on success.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn delete_handler (
async fn delete_handler (
State ( repo) : State < Repo > ,
State ( state) : State < AppState > ,
Path ( id ) : Path < String > ,
Path ( id ) : Path < String > ,
headers : HeaderMap ,
headers : HeaderMap ,
) -> Response {
) -> Response {
@ -427,7 +449,7 @@ async fn delete_handler(
return error_response ( StatusCode ::FORBIDDEN , "X-Auth-Code header is required" ) ;
return error_response ( StatusCode ::FORBIDDEN , "X-Auth-Code header is required" ) ;
} ;
} ;
match repo. delete_quote ( & id , & auth_code ) . await {
match state. repo. delete_quote ( & id , & auth_code ) . await {
Ok ( DeleteResult ::Deleted ) = > StatusCode ::NO_CONTENT . into_response ( ) ,
Ok ( DeleteResult ::Deleted ) = > StatusCode ::NO_CONTENT . into_response ( ) ,
Ok ( DeleteResult ::NotFound ) = > error_response ( StatusCode ::NOT_FOUND , "quote not found" ) ,
Ok ( DeleteResult ::NotFound ) = > error_response ( StatusCode ::NOT_FOUND , "quote not found" ) ,
Ok ( DeleteResult ::Forbidden ) = > error_response ( StatusCode ::FORBIDDEN , "forbidden" ) ,
Ok ( DeleteResult ::Forbidden ) = > error_response ( StatusCode ::FORBIDDEN , "forbidden" ) ,
@ -446,14 +468,14 @@ async fn delete_handler(
///
///
/// Returns `403 Forbidden` if the header is missing or the code is incorrect.
/// Returns `403 Forbidden` if the header is missing or the code is incorrect.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn lock_submissions ( State ( repo) : State < Repo > , headers : HeaderMap ) -> Response {
async fn lock_submissions ( State ( state) : State < AppState > , headers : HeaderMap ) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
if ! verify_admin_code ( & state , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
}
match repo. set_submissions_locked ( true ) . await {
match state. repo. set_submissions_locked ( true ) . await {
Ok ( ( ) ) = > Json ( serde_json ::json ! ( { "submissions_locked" : true } ) ) . into_response ( ) ,
Ok ( ( ) ) = > Json ( serde_json ::json ! ( { "submissions_locked" : true } ) ) . into_response ( ) ,
Err ( e ) = > db_error_response ( e ) ,
Err ( e ) = > db_error_response ( e ) ,
}
}
@ -470,14 +492,14 @@ async fn lock_submissions(State(repo): State<Repo>, headers: HeaderMap) -> Respo
///
///
/// Returns `403 Forbidden` if the header is missing or the code is incorrect.
/// Returns `403 Forbidden` if the header is missing or the code is incorrect.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn unlock_submissions ( State ( repo) : State < Repo > , headers : HeaderMap ) -> Response {
async fn unlock_submissions ( State ( state) : State < AppState > , headers : HeaderMap ) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
if ! verify_admin_code ( & state , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
}
match repo. set_submissions_locked ( false ) . await {
match state. repo. set_submissions_locked ( false ) . await {
Ok ( ( ) ) = > Json ( serde_json ::json ! ( { "submissions_locked" : false } ) ) . into_response ( ) ,
Ok ( ( ) ) = > Json ( serde_json ::json ! ( { "submissions_locked" : false } ) ) . into_response ( ) ,
Err ( e ) = > db_error_response ( e ) ,
Err ( e ) = > db_error_response ( e ) ,
}
}
@ -518,15 +540,25 @@ struct ResetAuthCodeResponse {
/// value, which the handler maps to `403`.
/// value, which the handler maps to `403`.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn reset_auth_code (
async fn reset_auth_code (
State ( repo) : State < Repo > ,
State ( state) : State < AppState > ,
headers : HeaderMap ,
headers : HeaderMap ,
Json ( payload ) : Json < ResetAuthCodeRequest > ,
Json ( payload ) : Json < ResetAuthCodeRequest > ,
) -> Response {
) -> Response {
if state . admin_secret . is_some ( ) {
return (
StatusCode ::CONFLICT ,
Json ( serde_json ::json ! ( {
"error" : "auth code is managed via ADMIN_AUTH_CODE secret — update it with wrangler"
} ) ) ,
)
. into_response ( ) ;
}
let admin_code = match extract_admin_code ( & headers ) {
let admin_code = match extract_admin_code ( & headers ) {
Some ( c ) = > c ,
Some ( c ) = > c ,
None = > return StatusCode ::FORBIDDEN . into_response ( ) ,
None = > return StatusCode ::FORBIDDEN . into_response ( ) ,
} ;
} ;
match repo
match state
. repo
. update_admin_auth_code ( & admin_code , payload . new_code . as_deref ( ) )
. update_admin_auth_code ( & admin_code , payload . new_code . as_deref ( ) )
. await
. await
{
{
@ -558,7 +590,7 @@ struct ReportInput {
/// `500 Internal Server Error` on a database failure.
/// `500 Internal Server Error` on a database failure.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn report_handler (
async fn report_handler (
State ( repo) : State < Repo > ,
State ( state) : State < AppState > ,
Path ( id ) : Path < String > ,
Path ( id ) : Path < String > ,
body : Option < Json < ReportInput > > ,
body : Option < Json < ReportInput > > ,
) -> Response {
) -> Response {
@ -572,7 +604,7 @@ async fn report_handler(
) ;
) ;
}
}
match repo. create_report ( & id , reason . as_deref ( ) ) . await {
match state. repo. create_report ( & id , reason . as_deref ( ) ) . await {
Ok ( ( ) ) = > StatusCode ::CREATED . into_response ( ) ,
Ok ( ( ) ) = > StatusCode ::CREATED . into_response ( ) ,
Err ( crate ::db ::DbError ::NotFound ) = > {
Err ( crate ::db ::DbError ::NotFound ) = > {
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
@ -599,17 +631,17 @@ struct AdminReportsParams {
/// is absent or the code is incorrect.
/// is absent or the code is incorrect.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn list_reports_handler (
async fn list_reports_handler (
State ( repo) : State < Repo > ,
State ( state) : State < AppState > ,
headers : HeaderMap ,
headers : HeaderMap ,
Query ( params ) : Query < AdminReportsParams > ,
Query ( params ) : Query < AdminReportsParams > ,
) -> Response {
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
if ! verify_admin_code ( & state , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
}
match repo. list_reports ( params . page ) . await {
match state. repo. list_reports ( params . page ) . await {
Ok ( result ) = > ( StatusCode ::OK , Json ( result ) ) . into_response ( ) ,
Ok ( result ) = > ( StatusCode ::OK , Json ( result ) ) . into_response ( ) ,
Err ( e ) = > db_error_response ( e ) ,
Err ( e ) = > db_error_response ( e ) ,
}
}
@ -625,17 +657,17 @@ async fn list_reports_handler(
/// exist.
/// exist.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn get_quote_reports_handler (
async fn get_quote_reports_handler (
State ( repo) : State < Repo > ,
State ( state) : State < AppState > ,
Path ( quote_id ) : Path < String > ,
Path ( quote_id ) : Path < String > ,
headers : HeaderMap ,
headers : HeaderMap ,
) -> Response {
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
if ! verify_admin_code ( & state , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
}
match repo. get_reports_for_quote ( & quote_id ) . await {
match state. repo. get_reports_for_quote ( & quote_id ) . await {
Ok ( result ) = > ( StatusCode ::OK , Json ( result ) ) . into_response ( ) ,
Ok ( result ) = > ( StatusCode ::OK , Json ( result ) ) . into_response ( ) ,
Err ( crate ::db ::DbError ::NotFound ) = > {
Err ( crate ::db ::DbError ::NotFound ) = > {
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
@ -655,17 +687,17 @@ async fn get_quote_reports_handler(
/// exist.
/// exist.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn admin_delete_quote_handler (
async fn admin_delete_quote_handler (
State ( repo) : State < Repo > ,
State ( state) : State < AppState > ,
Path ( quote_id ) : Path < String > ,
Path ( quote_id ) : Path < String > ,
headers : HeaderMap ,
headers : HeaderMap ,
) -> Response {
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
if ! verify_admin_code ( & state , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
}
match repo. admin_delete_quote ( & quote_id ) . await {
match state. repo. admin_delete_quote ( & quote_id ) . await {
Ok ( ( ) ) = > StatusCode ::NO_CONTENT . into_response ( ) ,
Ok ( ( ) ) = > StatusCode ::NO_CONTENT . into_response ( ) ,
Err ( crate ::db ::DbError ::NotFound ) = > {
Err ( crate ::db ::DbError ::NotFound ) = > {
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
@ -684,17 +716,17 @@ async fn admin_delete_quote_handler(
/// exist.
/// exist.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn hide_quote_handler (
async fn hide_quote_handler (
State ( repo) : State < Repo > ,
State ( state) : State < AppState > ,
Path ( quote_id ) : Path < String > ,
Path ( quote_id ) : Path < String > ,
headers : HeaderMap ,
headers : HeaderMap ,
) -> Response {
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
if ! verify_admin_code ( & state , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
}
match repo. hide_quote ( & quote_id ) . await {
match state. repo. hide_quote ( & quote_id ) . await {
Ok ( ( ) ) = > Json ( serde_json ::json ! ( { "hidden" : true } ) ) . into_response ( ) ,
Ok ( ( ) ) = > Json ( serde_json ::json ! ( { "hidden" : true } ) ) . into_response ( ) ,
Err ( crate ::db ::DbError ::NotFound ) = > {
Err ( crate ::db ::DbError ::NotFound ) = > {
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
@ -713,17 +745,17 @@ async fn hide_quote_handler(
/// exist.
/// exist.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn clear_reports_handler (
async fn clear_reports_handler (
State ( repo) : State < Repo > ,
State ( state) : State < AppState > ,
Path ( quote_id ) : Path < String > ,
Path ( quote_id ) : Path < String > ,
headers : HeaderMap ,
headers : HeaderMap ,
) -> Response {
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
if ! verify_admin_code ( & state , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
}
match repo. clear_reports ( & quote_id ) . await {
match state. repo. clear_reports ( & quote_id ) . await {
Ok ( ( ) ) = > StatusCode ::NO_CONTENT . into_response ( ) ,
Ok ( ( ) ) = > StatusCode ::NO_CONTENT . into_response ( ) ,
Err ( crate ::db ::DbError ::NotFound ) = > {
Err ( crate ::db ::DbError ::NotFound ) = > {
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
@ -745,7 +777,10 @@ async fn clear_reports_handler(
/// both bounds via `tokio_rusqlite::Connection`.
/// both bounds via `tokio_rusqlite::Connection`.
///
///
/// [`NativeRepository`]: crate::db::NativeRepository
/// [`NativeRepository`]: crate::db::NativeRepository
pub fn router ( repo : Arc < dyn QuoteRepository + Send + Sync > ) -> Router {
pub fn router (
repo : Arc < dyn QuoteRepository + Send + Sync > ,
admin_secret : Option < String > ,
) -> Router {
Router ::new ( )
Router ::new ( )
// Meta
// Meta
. route ( "/api/" , get ( openapi_handler ) )
. route ( "/api/" , get ( openapi_handler ) )
@ -782,7 +817,7 @@ pub fn router(repo: Arc<dyn QuoteRepository + Send + Sync>) -> Router {
. route ( "/api/quotes" , put ( create_handler ) )
. route ( "/api/quotes" , put ( create_handler ) )
. route ( "/api/quotes/{id}" , post ( update_handler ) )
. route ( "/api/quotes/{id}" , post ( update_handler ) )
. route ( "/api/quotes/{id}" , delete ( delete_handler ) )
. route ( "/api/quotes/{id}" , delete ( delete_handler ) )
. with_state ( repo)
. with_state ( AppState { repo, admin_secret } )
}
}
#[ cfg(test) ]
#[ cfg(test) ]
@ -1099,7 +1134,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_openapi_endpoint ( ) {
async fn test_openapi_endpoint ( ) {
let app = router ( MockRepo ::empty ( ) );
let app = router ( MockRepo ::empty ( ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/" )
. uri ( "/api/" )
@ -1113,7 +1148,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_list_quotes ( ) {
async fn test_list_quotes ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) );
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/quotes" )
. uri ( "/api/quotes" )
@ -1127,7 +1162,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_random_quote_not_found ( ) {
async fn test_random_quote_not_found ( ) {
let app = router ( MockRepo ::empty ( ) );
let app = router ( MockRepo ::empty ( ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/quotes/random" )
. uri ( "/api/quotes/random" )
@ -1139,7 +1174,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_random_quote_found ( ) {
async fn test_random_quote_found ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) );
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/quotes/random" )
. uri ( "/api/quotes/random" )
@ -1151,7 +1186,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_get_quote_not_found ( ) {
async fn test_get_quote_not_found ( ) {
let app = router ( MockRepo ::empty ( ) );
let app = router ( MockRepo ::empty ( ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/quotes/nonexistent" )
. uri ( "/api/quotes/nonexistent" )
@ -1163,7 +1198,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_get_quote_found ( ) {
async fn test_get_quote_found ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) );
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/quotes/abc-123" )
. uri ( "/api/quotes/abc-123" )
@ -1175,7 +1210,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_create_quote ( ) {
async fn test_create_quote ( ) {
let app = router ( MockRepo ::empty ( ) );
let app = router ( MockRepo ::empty ( ) , None );
let body = serde_json ::json ! ( {
let body = serde_json ::json ! ( {
"text" : "New quote" ,
"text" : "New quote" ,
"author" : "Author" ,
"author" : "Author" ,
@ -1198,7 +1233,7 @@ mod tests {
/// with `{"error": "submissions are closed"}`.
/// with `{"error": "submissions are closed"}`.
#[ tokio::test ]
#[ tokio::test ]
async fn test_create_quote_locked_returns_423 ( ) {
async fn test_create_quote_locked_returns_423 ( ) {
let app = router ( MockRepo ::with_submissions_locked ( true ) );
let app = router ( MockRepo ::with_submissions_locked ( true ) , None );
let body = serde_json ::json ! ( {
let body = serde_json ::json ! ( {
"text" : "Locked quote" ,
"text" : "Locked quote" ,
"author" : "Author" ,
"author" : "Author" ,
@ -1220,7 +1255,7 @@ mod tests {
/// (existing behaviour is unchanged).
/// (existing behaviour is unchanged).
#[ tokio::test ]
#[ tokio::test ]
async fn test_create_quote_unlocked_returns_201 ( ) {
async fn test_create_quote_unlocked_returns_201 ( ) {
let app = router ( MockRepo ::with_submissions_locked ( false ) );
let app = router ( MockRepo ::with_submissions_locked ( false ) , None );
let body = serde_json ::json ! ( {
let body = serde_json ::json ! ( {
"text" : "Unlocked quote" ,
"text" : "Unlocked quote" ,
"author" : "Author" ,
"author" : "Author" ,
@ -1248,7 +1283,7 @@ mod tests {
repo . set_submissions_locked ( false )
repo . set_submissions_locked ( false )
. await
. await
. expect ( "set_submissions_locked should not fail" ) ;
. expect ( "set_submissions_locked should not fail" ) ;
let app = router ( repo );
let app = router ( repo , None );
let body = serde_json ::json ! ( {
let body = serde_json ::json ! ( {
"text" : "Re-enabled quote" ,
"text" : "Re-enabled quote" ,
"author" : "Author" ,
"author" : "Author" ,
@ -1269,7 +1304,7 @@ mod tests {
#[ 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" ) , None );
let body = serde_json ::json ! ( { "text" : "Updated" } ) ;
let body = serde_json ::json ! ( { "text" : "Updated" } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
@ -1283,7 +1318,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_update_quote_wrong_auth ( ) {
async fn test_update_quote_wrong_auth ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) );
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) , None );
let body = serde_json ::json ! ( { "text" : "Updated" } ) ;
let body = serde_json ::json ! ( { "text" : "Updated" } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
@ -1298,7 +1333,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_update_quote_success ( ) {
async fn test_update_quote_success ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) );
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) , None );
let body = serde_json ::json ! ( { "text" : "Updated text" } ) ;
let body = serde_json ::json ! ( { "text" : "Updated text" } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
@ -1315,7 +1350,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_delete_quote_missing_auth ( ) {
async fn test_delete_quote_missing_auth ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) );
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::DELETE )
. method ( Method ::DELETE )
. uri ( "/api/quotes/abc-123" )
. uri ( "/api/quotes/abc-123" )
@ -1327,7 +1362,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_delete_quote_success ( ) {
async fn test_delete_quote_success ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) );
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "correct" ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::DELETE )
. method ( Method ::DELETE )
. uri ( "/api/quotes/abc-123" )
. uri ( "/api/quotes/abc-123" )
@ -1340,7 +1375,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_delete_quote_not_found ( ) {
async fn test_delete_quote_not_found ( ) {
let app = router ( MockRepo ::empty ( ) );
let app = router ( MockRepo ::empty ( ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::DELETE )
. method ( Method ::DELETE )
. uri ( "/api/quotes/nonexistent" )
. uri ( "/api/quotes/nonexistent" )
@ -1421,7 +1456,7 @@ mod tests {
/// the repo's submissions lock is unset (the default `false` state).
/// the repo's submissions lock is unset (the default `false` state).
#[ tokio::test ]
#[ tokio::test ]
async fn test_get_status_unlocked ( ) {
async fn test_get_status_unlocked ( ) {
let app = router ( MockRepo ::empty ( ) );
let app = router ( MockRepo ::empty ( ) , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/status" )
. uri ( "/api/status" )
@ -1441,7 +1476,7 @@ mod tests {
repo . set_submissions_locked ( true )
repo . set_submissions_locked ( true )
. await
. await
. expect ( "set_submissions_locked should not fail" ) ;
. expect ( "set_submissions_locked should not fail" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/status" )
. uri ( "/api/status" )
@ -1472,7 +1507,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_lock_submissions_correct_code_returns_200 ( ) {
async fn test_lock_submissions_correct_code_returns_200 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
. uri ( "/api/admin/lock" )
. uri ( "/api/admin/lock" )
@ -1494,7 +1529,7 @@ mod tests {
repo . set_submissions_locked ( true )
repo . set_submissions_locked ( true )
. await
. await
. expect ( "set_submissions_locked should not fail" ) ;
. expect ( "set_submissions_locked should not fail" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
. uri ( "/api/admin/unlock" )
. uri ( "/api/admin/unlock" )
@ -1511,7 +1546,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_lock_submissions_wrong_code_returns_403 ( ) {
async fn test_lock_submissions_wrong_code_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
. uri ( "/api/admin/lock" )
. uri ( "/api/admin/lock" )
@ -1526,7 +1561,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_unlock_submissions_missing_header_returns_403 ( ) {
async fn test_unlock_submissions_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
. uri ( "/api/admin/unlock" )
. uri ( "/api/admin/unlock" )
@ -1545,7 +1580,7 @@ mod tests {
repo . set_submissions_locked ( true )
repo . set_submissions_locked ( true )
. await
. await
. expect ( "initial lock should not fail" ) ;
. expect ( "initial lock should not fail" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
. uri ( "/api/admin/lock" )
. uri ( "/api/admin/lock" )
@ -1566,7 +1601,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_reset_auth_code_correct_code_no_new_code_returns_200 ( ) {
async fn test_reset_auth_code_correct_code_no_new_code_returns_200 ( ) {
let repo = MockRepo ::with_admin_code ( "current-secret" ) ;
let repo = MockRepo ::with_admin_code ( "current-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let body = serde_json ::json ! ( { } ) ;
let body = serde_json ::json ! ( { } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
@ -1593,7 +1628,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_reset_auth_code_correct_code_explicit_new_code_returns_200 ( ) {
async fn test_reset_auth_code_correct_code_explicit_new_code_returns_200 ( ) {
let repo = MockRepo ::with_admin_code ( "current-secret" ) ;
let repo = MockRepo ::with_admin_code ( "current-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let body = serde_json ::json ! ( { "new_code" : "brand-new-passphrase" } ) ;
let body = serde_json ::json ! ( { "new_code" : "brand-new-passphrase" } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
@ -1615,7 +1650,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_reset_auth_code_wrong_code_returns_403 ( ) {
async fn test_reset_auth_code_wrong_code_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let body = serde_json ::json ! ( { } ) ;
let body = serde_json ::json ! ( { } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
@ -1632,7 +1667,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_reset_auth_code_missing_header_returns_403 ( ) {
async fn test_reset_auth_code_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let body = serde_json ::json ! ( { } ) ;
let body = serde_json ::json ! ( { } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
@ -1649,7 +1684,7 @@ mod tests {
/// `POST /api/quotes/:id/report` with a known quote ID returns `201 Created`.
/// `POST /api/quotes/:id/report` with a known quote ID returns `201 Created`.
#[ tokio::test ]
#[ tokio::test ]
async fn test_report_success ( ) {
async fn test_report_success ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) );
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) , None );
let body = serde_json ::json ! ( { "reason" : "inappropriate content" } ) ;
let body = serde_json ::json ! ( { "reason" : "inappropriate content" } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
@ -1664,7 +1699,7 @@ mod tests {
/// `POST /api/quotes/:id/report` with an unknown quote ID returns `404 Not Found`.
/// `POST /api/quotes/:id/report` with an unknown quote ID returns `404 Not Found`.
#[ tokio::test ]
#[ tokio::test ]
async fn test_report_quote_not_found ( ) {
async fn test_report_quote_not_found ( ) {
let app = router ( MockRepo ::empty ( ) );
let app = router ( MockRepo ::empty ( ) , None );
let body = serde_json ::json ! ( { } ) ;
let body = serde_json ::json ! ( { } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
@ -1680,7 +1715,7 @@ mod tests {
/// returns `400 Bad Request`.
/// returns `400 Bad Request`.
#[ tokio::test ]
#[ tokio::test ]
async fn test_report_reason_too_long ( ) {
async fn test_report_reason_too_long ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) );
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) , None );
let long_reason = "x" . repeat ( 257 ) ;
let long_reason = "x" . repeat ( 257 ) ;
let body = serde_json ::json ! ( { "reason" : long_reason } ) ;
let body = serde_json ::json ! ( { "reason" : long_reason } ) ;
let req = Request ::builder ( )
let req = Request ::builder ( )
@ -1708,7 +1743,7 @@ mod tests {
. header ( "Content-Type" , "application/json" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( first_body . to_string ( ) ) )
. body ( Body ::from ( first_body . to_string ( ) ) )
. unwrap ( ) ;
. unwrap ( ) ;
let app = router ( Arc ::clone ( & repo ) as Repo );
let app = router ( Arc ::clone ( & repo ) as Repo , None );
let ( status , _ ) = send ( app , first_req ) . await ;
let ( status , _ ) = send ( app , first_req ) . await ;
assert_eq! ( status , StatusCode ::OK , "first reset must succeed" ) ;
assert_eq! ( status , StatusCode ::OK , "first reset must succeed" ) ;
@ -1721,7 +1756,7 @@ mod tests {
. header ( "Content-Type" , "application/json" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( second_body . to_string ( ) ) )
. body ( Body ::from ( second_body . to_string ( ) ) )
. unwrap ( ) ;
. unwrap ( ) ;
let app2 = router ( Arc ::clone ( & repo ) as Repo );
let app2 = router ( Arc ::clone ( & repo ) as Repo , None );
let ( status2 , _ ) = send ( app2 , second_req ) . await ;
let ( status2 , _ ) = send ( app2 , second_req ) . await ;
assert_eq! (
assert_eq! (
status2 ,
status2 ,
@ -1738,7 +1773,7 @@ mod tests {
. header ( "Content-Type" , "application/json" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( third_body . to_string ( ) ) )
. body ( Body ::from ( third_body . to_string ( ) ) )
. unwrap ( ) ;
. unwrap ( ) ;
let app3 = router ( repo as Repo );
let app3 = router ( repo as Repo , None );
let ( status3 , resp_body3 ) = send ( app3 , third_req ) . await ;
let ( status3 , resp_body3 ) = send ( app3 , third_req ) . await ;
assert_eq! (
assert_eq! (
status3 ,
status3 ,
@ -1759,7 +1794,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_list_reports_correct_code_returns_200 ( ) {
async fn test_list_reports_correct_code_returns_200 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. uri ( "/api/admin/reports" )
@ -1777,7 +1812,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_list_reports_missing_header_returns_403 ( ) {
async fn test_list_reports_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. uri ( "/api/admin/reports" )
@ -1791,7 +1826,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_list_reports_wrong_code_returns_403 ( ) {
async fn test_list_reports_wrong_code_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. uri ( "/api/admin/reports" )
@ -1826,7 +1861,7 @@ mod tests {
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
drop ( quotes ) ;
drop ( quotes ) ;
let app = router ( Arc ::clone ( & repo ) as Repo );
let app = router ( Arc ::clone ( & repo ) as Repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( format! ( "/api/admin/reports/{quote_id}" ) )
. uri ( format! ( "/api/admin/reports/{quote_id}" ) )
@ -1844,7 +1879,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_get_quote_reports_not_found_returns_404 ( ) {
async fn test_get_quote_reports_not_found_returns_404 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/admin/reports/nonexistent" )
. uri ( "/api/admin/reports/nonexistent" )
@ -1859,7 +1894,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_get_quote_reports_missing_header_returns_403 ( ) {
async fn test_get_quote_reports_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::GET )
. method ( Method ::GET )
. uri ( "/api/admin/reports/any-id" )
. uri ( "/api/admin/reports/any-id" )
@ -1891,7 +1926,7 @@ mod tests {
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
drop ( quotes ) ;
drop ( quotes ) ;
let app = router ( Arc ::clone ( & repo ) as Repo );
let app = router ( Arc ::clone ( & repo ) as Repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::DELETE )
. method ( Method ::DELETE )
. uri ( format! ( "/api/admin/reports/{quote_id}/quote" ) )
. uri ( format! ( "/api/admin/reports/{quote_id}/quote" ) )
@ -1911,7 +1946,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_admin_delete_quote_not_found_returns_404 ( ) {
async fn test_admin_delete_quote_not_found_returns_404 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::DELETE )
. method ( Method ::DELETE )
. uri ( "/api/admin/reports/nonexistent/quote" )
. uri ( "/api/admin/reports/nonexistent/quote" )
@ -1927,7 +1962,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_admin_delete_quote_missing_header_returns_403 ( ) {
async fn test_admin_delete_quote_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::DELETE )
. method ( Method ::DELETE )
. uri ( "/api/admin/reports/any-id/quote" )
. uri ( "/api/admin/reports/any-id/quote" )
@ -1959,7 +1994,7 @@ mod tests {
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
drop ( quotes ) ;
drop ( quotes ) ;
let app = router ( Arc ::clone ( & repo ) as Repo );
let app = router ( Arc ::clone ( & repo ) as Repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
. uri ( format! ( "/api/admin/reports/{quote_id}/hide" ) )
. uri ( format! ( "/api/admin/reports/{quote_id}/hide" ) )
@ -1981,7 +2016,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_hide_quote_not_found_returns_404 ( ) {
async fn test_hide_quote_not_found_returns_404 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
. uri ( "/api/admin/reports/nonexistent/hide" )
. uri ( "/api/admin/reports/nonexistent/hide" )
@ -1997,7 +2032,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_hide_quote_missing_header_returns_403 ( ) {
async fn test_hide_quote_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::POST )
. method ( Method ::POST )
. uri ( "/api/admin/reports/any-id/hide" )
. uri ( "/api/admin/reports/any-id/hide" )
@ -2029,7 +2064,7 @@ mod tests {
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
drop ( quotes ) ;
drop ( quotes ) ;
let app = router ( Arc ::clone ( & repo ) as Repo );
let app = router ( Arc ::clone ( & repo ) as Repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::DELETE )
. method ( Method ::DELETE )
. uri ( format! ( "/api/admin/reports/{quote_id}/reports" ) )
. uri ( format! ( "/api/admin/reports/{quote_id}/reports" ) )
@ -2045,7 +2080,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_clear_reports_not_found_returns_404 ( ) {
async fn test_clear_reports_not_found_returns_404 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::DELETE )
. method ( Method ::DELETE )
. uri ( "/api/admin/reports/nonexistent/reports" )
. uri ( "/api/admin/reports/nonexistent/reports" )
@ -2061,7 +2096,7 @@ mod tests {
#[ tokio::test ]
#[ tokio::test ]
async fn test_clear_reports_missing_header_returns_403 ( ) {
async fn test_clear_reports_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo );
let app = router ( repo , None );
let req = Request ::builder ( )
let req = Request ::builder ( )
. method ( Method ::DELETE )
. method ( Method ::DELETE )
. uri ( "/api/admin/reports/any-id/reports" )
. uri ( "/api/admin/reports/any-id/reports" )
@ -2070,6 +2105,42 @@ mod tests {
let ( status , _ ) = send ( app , req ) . await ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
}
// ── ADMIN_AUTH_CODE secret override tests ──────────────────────────────────
/// When `admin_secret` is `Some`, it takes precedence over the DB-stored code.
#[ tokio::test ]
async fn test_admin_secret_overrides_db_code ( ) {
// DB has "db-code" but the secret is "secret-code".
let repo = MockRepo ::with_admin_code ( "db-code" ) ;
let app = router ( repo , Some ( "secret-code" . to_owned ( ) ) ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/lock" )
. header ( "X-Admin-Code" , "secret-code" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::OK ) ;
}
/// When `admin_secret` is `Some`, `reset-auth-code` returns `409 Conflict`.
#[ tokio::test ]
async fn test_reset_auth_code_blocked_when_secret_active ( ) {
let repo = MockRepo ::with_admin_code ( "db-code" ) ;
let app = router ( repo , Some ( "secret-code" . to_owned ( ) ) ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reset-auth-code" )
. header ( "Content-Type" , "application/json" )
. header ( "X-Admin-Code" , "secret-code" )
. body ( Body ::from ( r#"{"new_code": "anything"}"# ) )
. unwrap ( ) ;
let ( status , body ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::CONFLICT ) ;
let v : serde_json ::Value = serde_json ::from_str ( & body ) . unwrap ( ) ;
assert! ( v [ "error" ] . as_str ( ) . unwrap ( ) . contains ( "ADMIN_AUTH_CODE" ) ) ;
}
}
}
// ── Integration tests (real NativeRepository + real SQLite) ─────────────────
// ── Integration tests (real NativeRepository + real SQLite) ─────────────────
@ -2111,7 +2182,7 @@ mod integration_tests {
. expect ( "failed to open test database" ) ;
. expect ( "failed to open test database" ) ;
repo . run_migrations ( ) . await . expect ( "migrations failed" ) ;
repo . run_migrations ( ) . await . expect ( "migrations failed" ) ;
let repo : Arc < dyn QuoteRepository + Send + Sync > = Arc ::new ( repo ) ;
let repo : Arc < dyn QuoteRepository + Send + Sync > = Arc ::new ( repo ) ;
( router ( repo ), f )
( router ( repo , None ), f )
}
}
// ── Body helpers ──────────────────────────────────────────────────────────
// ── Body helpers ──────────────────────────────────────────────────────────
@ -2940,7 +3011,7 @@ mod integration_tests {
. await
. await
. expect ( "failed to seed submissions lock" ) ;
. expect ( "failed to seed submissions lock" ) ;
let repo : Arc < dyn QuoteRepository + Send + Sync > = Arc ::new ( repo ) ;
let repo : Arc < dyn QuoteRepository + Send + Sync > = Arc ::new ( repo ) ;
( router ( repo ), f )
( router ( repo , None ), f )
}
}
/// Submit a report for a quote via `POST /api/quotes/:id/report`.
/// Submit a report for a quote via `POST /api/quotes/:id/report`.