feat(quotesdb): support ADMIN_AUTH_CODE Cloudflare secret for admin auth

Merge qdb-api-d4a624 — ticket d4a624
quotesdb
Elijah Voigt 3 months ago
commit 9418bd4b0b

@ -28,3 +28,12 @@ resource "null_resource" "worker_deploy" {
} }
} }
} }
# The ADMIN_AUTH_CODE worker secret must be set before the first deploy.
# Run once (or whenever you need to rotate it):
#
# wrangler secret put ADMIN_AUTH_CODE --config wrangler.toml
#
# When ADMIN_AUTH_CODE is set, it overrides the DB-stored admin code and the
# reset-auth-code endpoint (/api/admin/reset-auth-code) returns 409 Conflict.
# To rotate it, use `wrangler secret put` again no redeploy needed.

@ -63,7 +63,7 @@ async fn main() {
let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo); let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo);
let app = handlers::router(repo); let app = handlers::router(repo, None);
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let addr = format!("0.0.0.0:{port}"); let addr = format!("0.0.0.0:{port}");

@ -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 repository. /// Verify that the supplied admin code matches the configured authority.
/// ///
/// 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`.

@ -351,7 +351,12 @@ pub async fn fetch(
.await .await
.map_err(|e| worker::Error::RustError(e.to_string()))?; .map_err(|e| worker::Error::RustError(e.to_string()))?;
// Seed admin auth code on first startup (no-op if already present). // Read optional ADMIN_AUTH_CODE secret from Worker environment.
// When set, it overrides the DB-stored admin code — no DB read needed.
let admin_secret = env.secret("ADMIN_AUTH_CODE").ok().map(|s| s.to_string());
// Seed DB admin code only when no secret is configured and no code exists yet.
if admin_secret.is_none() {
if repo if repo
.get_admin_auth_code() .get_admin_auth_code()
.await .await
@ -363,6 +368,7 @@ pub async fn fetch(
.await .await
.map_err(|e| worker::Error::RustError(e.to_string()))?; .map_err(|e| worker::Error::RustError(e.to_string()))?;
} }
}
// Seed submissions_locked = '0' on first startup (INSERT OR IGNORE — no-op // Seed submissions_locked = '0' on first startup (INSERT OR IGNORE — no-op
// if the key already exists, so this never overwrites an active lock). // if the key already exists, so this never overwrites an active lock).
@ -375,7 +381,7 @@ pub async fn fetch(
let repo: Arc<dyn crate::db::QuoteRepository + Send + Sync> = Arc::new(repo); let repo: Arc<dyn crate::db::QuoteRepository + Send + Sync> = Arc::new(repo);
// Build the Axum router with all routes registered. // Build the Axum router with all routes registered.
let mut router = crate::handlers::router(repo); let mut router = crate::handlers::router(repo, admin_secret);
// Call the Axum router directly; the http feature ensures request/response // Call the Axum router directly; the http feature ensures request/response
// types are compatible with standard http crate types that Axum expects. // types are compatible with standard http crate types that Axum expects.

Loading…
Cancel
Save