|
|
|
|
@ -31,6 +31,19 @@ use crate::db::{DeleteResult, QuoteRepository};
|
|
|
|
|
/// shared across Tokio tasks. `NativeRepository` satisfies both bounds.
|
|
|
|
|
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 ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// 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.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
pub async fn get_status(State(repo): State<Repo>) -> Response {
|
|
|
|
|
match repo.get_submissions_locked().await {
|
|
|
|
|
pub async fn get_status(State(state): State<AppState>) -> Response {
|
|
|
|
|
match state.repo.get_submissions_locked().await {
|
|
|
|
|
Ok(locked) => Json(serde_json::json!({ "submissions_locked": locked })).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.
|
|
|
|
|
/// `date_after_month` provided without `date_after_year`).
|
|
|
|
|
#[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
|
|
|
|
|
if params.date_after_month.is_some() && params.date_after_year.is_none() {
|
|
|
|
|
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
|
|
|
|
|
/// id parameter.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn random_handler(State(repo): State<Repo>) -> Response {
|
|
|
|
|
match repo.get_random_quote().await {
|
|
|
|
|
async fn random_handler(State(state): State<AppState>) -> Response {
|
|
|
|
|
match state.repo.get_random_quote().await {
|
|
|
|
|
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
|
|
|
|
|
Ok(None) => error_response(StatusCode::NOT_FOUND, "no quotes in database"),
|
|
|
|
|
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.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn get_quote_handler(State(repo): State<Repo>, Path(id): Path<String>) -> Response {
|
|
|
|
|
match repo.get_quote(&id).await {
|
|
|
|
|
async fn get_quote_handler(State(state): State<AppState>, Path(id): Path<String>) -> Response {
|
|
|
|
|
match state.repo.get_quote(&id).await {
|
|
|
|
|
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
|
|
|
|
|
Ok(None) => error_response(StatusCode::NOT_FOUND, "quote not found"),
|
|
|
|
|
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`
|
|
|
|
|
/// field. This check is skipped on wasm32 targets (Workers runtime).
|
|
|
|
|
#[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.
|
|
|
|
|
match repo.get_submissions_locked().await {
|
|
|
|
|
match state.repo.get_submissions_locked().await {
|
|
|
|
|
Ok(true) => {
|
|
|
|
|
return (
|
|
|
|
|
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)) => (
|
|
|
|
|
StatusCode::CREATED,
|
|
|
|
|
Json(CreateResponse { quote, auth_code }),
|
|
|
|
|
@ -379,14 +396,19 @@ fn extract_admin_code(headers: &HeaderMap) -> Option<String> {
|
|
|
|
|
.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`]
|
|
|
|
|
/// and compares it with the supplied code using standard string equality.
|
|
|
|
|
/// When `state.admin_secret` is `Some`, the secret is compared directly and
|
|
|
|
|
/// 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,
|
|
|
|
|
/// or the database query fails.
|
|
|
|
|
async fn verify_admin_code(repo: &Repo, code: &str) -> bool {
|
|
|
|
|
match repo.get_admin_auth_code().await {
|
|
|
|
|
async fn verify_admin_code(state: &AppState, code: &str) -> bool {
|
|
|
|
|
if let Some(secret) = &state.admin_secret {
|
|
|
|
|
return secret == code;
|
|
|
|
|
}
|
|
|
|
|
match state.repo.get_admin_auth_code().await {
|
|
|
|
|
Ok(Some(stored)) => stored == code,
|
|
|
|
|
_ => 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.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn update_handler(
|
|
|
|
|
State(repo): State<Repo>,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(id): Path<String>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Json(input): Json<UpdateQuoteInput>,
|
|
|
|
|
@ -407,7 +429,7 @@ async fn update_handler(
|
|
|
|
|
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(),
|
|
|
|
|
Err(e) => db_error_response(e),
|
|
|
|
|
}
|
|
|
|
|
@ -419,7 +441,7 @@ async fn update_handler(
|
|
|
|
|
/// `404` if not found, or `204 No Content` on success.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn delete_handler(
|
|
|
|
|
State(repo): State<Repo>,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(id): Path<String>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
) -> Response {
|
|
|
|
|
@ -427,7 +449,7 @@ async fn delete_handler(
|
|
|
|
|
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::NotFound) => error_response(StatusCode::NOT_FOUND, "quote not found"),
|
|
|
|
|
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.
|
|
|
|
|
#[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 {
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
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(),
|
|
|
|
|
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.
|
|
|
|
|
#[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 {
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
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(),
|
|
|
|
|
Err(e) => db_error_response(e),
|
|
|
|
|
}
|
|
|
|
|
@ -518,15 +540,25 @@ struct ResetAuthCodeResponse {
|
|
|
|
|
/// value, which the handler maps to `403`.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn reset_auth_code(
|
|
|
|
|
State(repo): State<Repo>,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Json(payload): Json<ResetAuthCodeRequest>,
|
|
|
|
|
) -> 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) {
|
|
|
|
|
Some(c) => c,
|
|
|
|
|
None => return StatusCode::FORBIDDEN.into_response(),
|
|
|
|
|
};
|
|
|
|
|
match repo
|
|
|
|
|
match state
|
|
|
|
|
.repo
|
|
|
|
|
.update_admin_auth_code(&admin_code, payload.new_code.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
@ -558,7 +590,7 @@ struct ReportInput {
|
|
|
|
|
/// `500 Internal Server Error` on a database failure.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn report_handler(
|
|
|
|
|
State(repo): State<Repo>,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(id): Path<String>,
|
|
|
|
|
body: Option<Json<ReportInput>>,
|
|
|
|
|
) -> 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(),
|
|
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
|
|
@ -599,17 +631,17 @@ struct AdminReportsParams {
|
|
|
|
|
/// is absent or the code is incorrect.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn list_reports_handler(
|
|
|
|
|
State(repo): State<Repo>,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Query(params): Query<AdminReportsParams>,
|
|
|
|
|
) -> Response {
|
|
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
|
|
};
|
|
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
|
|
if !verify_admin_code(&state, &code).await {
|
|
|
|
|
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(),
|
|
|
|
|
Err(e) => db_error_response(e),
|
|
|
|
|
}
|
|
|
|
|
@ -625,17 +657,17 @@ async fn list_reports_handler(
|
|
|
|
|
/// exist.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn get_quote_reports_handler(
|
|
|
|
|
State(repo): State<Repo>,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(quote_id): Path<String>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
) -> Response {
|
|
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
|
|
};
|
|
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
|
|
if !verify_admin_code(&state, &code).await {
|
|
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
|
|
}
|
|
|
|
|
match repo.get_reports_for_quote("e_id).await {
|
|
|
|
|
match state.repo.get_reports_for_quote("e_id).await {
|
|
|
|
|
Ok(result) => (StatusCode::OK, Json(result)).into_response(),
|
|
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
|
|
@ -655,17 +687,17 @@ async fn get_quote_reports_handler(
|
|
|
|
|
/// exist.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn admin_delete_quote_handler(
|
|
|
|
|
State(repo): State<Repo>,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(quote_id): Path<String>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
) -> Response {
|
|
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
|
|
};
|
|
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
|
|
if !verify_admin_code(&state, &code).await {
|
|
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
|
|
}
|
|
|
|
|
match repo.admin_delete_quote("e_id).await {
|
|
|
|
|
match state.repo.admin_delete_quote("e_id).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
|
|
@ -684,17 +716,17 @@ async fn admin_delete_quote_handler(
|
|
|
|
|
/// exist.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn hide_quote_handler(
|
|
|
|
|
State(repo): State<Repo>,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(quote_id): Path<String>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
) -> Response {
|
|
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
|
|
};
|
|
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
|
|
if !verify_admin_code(&state, &code).await {
|
|
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
|
|
}
|
|
|
|
|
match repo.hide_quote("e_id).await {
|
|
|
|
|
match state.repo.hide_quote("e_id).await {
|
|
|
|
|
Ok(()) => Json(serde_json::json!({ "hidden": true })).into_response(),
|
|
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
|
|
@ -713,17 +745,17 @@ async fn hide_quote_handler(
|
|
|
|
|
/// exist.
|
|
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
|
|
async fn clear_reports_handler(
|
|
|
|
|
State(repo): State<Repo>,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(quote_id): Path<String>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
) -> Response {
|
|
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
|
|
};
|
|
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
|
|
if !verify_admin_code(&state, &code).await {
|
|
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
|
|
}
|
|
|
|
|
match repo.clear_reports("e_id).await {
|
|
|
|
|
match state.repo.clear_reports("e_id).await {
|
|
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
|
|
@ -745,7 +777,10 @@ async fn clear_reports_handler(
|
|
|
|
|
/// both bounds via `tokio_rusqlite::Connection`.
|
|
|
|
|
///
|
|
|
|
|
/// [`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()
|
|
|
|
|
// Meta
|
|
|
|
|
.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/{id}", post(update_handler))
|
|
|
|
|
.route("/api/quotes/{id}", delete(delete_handler))
|
|
|
|
|
.with_state(repo)
|
|
|
|
|
.with_state(AppState { repo, admin_secret })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
@ -1099,7 +1134,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_openapi_endpoint() {
|
|
|
|
|
let app = router(MockRepo::empty());
|
|
|
|
|
let app = router(MockRepo::empty(), None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/")
|
|
|
|
|
@ -1113,7 +1148,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/quotes")
|
|
|
|
|
@ -1127,7 +1162,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_random_quote_not_found() {
|
|
|
|
|
let app = router(MockRepo::empty());
|
|
|
|
|
let app = router(MockRepo::empty(), None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/quotes/random")
|
|
|
|
|
@ -1139,7 +1174,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/quotes/random")
|
|
|
|
|
@ -1151,7 +1186,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_get_quote_not_found() {
|
|
|
|
|
let app = router(MockRepo::empty());
|
|
|
|
|
let app = router(MockRepo::empty(), None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/quotes/nonexistent")
|
|
|
|
|
@ -1163,7 +1198,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/quotes/abc-123")
|
|
|
|
|
@ -1175,7 +1210,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_create_quote() {
|
|
|
|
|
let app = router(MockRepo::empty());
|
|
|
|
|
let app = router(MockRepo::empty(), None);
|
|
|
|
|
let body = serde_json::json!({
|
|
|
|
|
"text": "New quote",
|
|
|
|
|
"author": "Author",
|
|
|
|
|
@ -1198,7 +1233,7 @@ mod tests {
|
|
|
|
|
/// with `{"error": "submissions are closed"}`.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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!({
|
|
|
|
|
"text": "Locked quote",
|
|
|
|
|
"author": "Author",
|
|
|
|
|
@ -1220,7 +1255,7 @@ mod tests {
|
|
|
|
|
/// (existing behaviour is unchanged).
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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!({
|
|
|
|
|
"text": "Unlocked quote",
|
|
|
|
|
"author": "Author",
|
|
|
|
|
@ -1248,7 +1283,7 @@ mod tests {
|
|
|
|
|
repo.set_submissions_locked(false)
|
|
|
|
|
.await
|
|
|
|
|
.expect("set_submissions_locked should not fail");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let body = serde_json::json!({
|
|
|
|
|
"text": "Re-enabled quote",
|
|
|
|
|
"author": "Author",
|
|
|
|
|
@ -1269,7 +1304,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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 req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
@ -1283,7 +1318,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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 req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
@ -1298,7 +1333,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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 req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
@ -1315,7 +1350,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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()
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
.uri("/api/quotes/abc-123")
|
|
|
|
|
@ -1327,7 +1362,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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()
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
.uri("/api/quotes/abc-123")
|
|
|
|
|
@ -1340,7 +1375,7 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_delete_quote_not_found() {
|
|
|
|
|
let app = router(MockRepo::empty());
|
|
|
|
|
let app = router(MockRepo::empty(), None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
.uri("/api/quotes/nonexistent")
|
|
|
|
|
@ -1421,7 +1456,7 @@ mod tests {
|
|
|
|
|
/// the repo's submissions lock is unset (the default `false` state).
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_get_status_unlocked() {
|
|
|
|
|
let app = router(MockRepo::empty());
|
|
|
|
|
let app = router(MockRepo::empty(), None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/status")
|
|
|
|
|
@ -1441,7 +1476,7 @@ mod tests {
|
|
|
|
|
repo.set_submissions_locked(true)
|
|
|
|
|
.await
|
|
|
|
|
.expect("set_submissions_locked should not fail");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/status")
|
|
|
|
|
@ -1472,7 +1507,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_lock_submissions_correct_code_returns_200() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/admin/lock")
|
|
|
|
|
@ -1494,7 +1529,7 @@ mod tests {
|
|
|
|
|
repo.set_submissions_locked(true)
|
|
|
|
|
.await
|
|
|
|
|
.expect("set_submissions_locked should not fail");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/admin/unlock")
|
|
|
|
|
@ -1511,7 +1546,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_lock_submissions_wrong_code_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("real-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/admin/lock")
|
|
|
|
|
@ -1526,7 +1561,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_unlock_submissions_missing_header_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/admin/unlock")
|
|
|
|
|
@ -1545,7 +1580,7 @@ mod tests {
|
|
|
|
|
repo.set_submissions_locked(true)
|
|
|
|
|
.await
|
|
|
|
|
.expect("initial lock should not fail");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/admin/lock")
|
|
|
|
|
@ -1566,7 +1601,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_reset_auth_code_correct_code_no_new_code_returns_200() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("current-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let body = serde_json::json!({});
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
@ -1593,7 +1628,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_reset_auth_code_correct_code_explicit_new_code_returns_200() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("current-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let body = serde_json::json!({ "new_code": "brand-new-passphrase" });
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
@ -1615,7 +1650,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_reset_auth_code_wrong_code_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("real-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let body = serde_json::json!({});
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
@ -1632,7 +1667,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_reset_auth_code_missing_header_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("real-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let body = serde_json::json!({});
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
@ -1649,7 +1684,7 @@ mod tests {
|
|
|
|
|
/// `POST /api/quotes/:id/report` with a known quote ID returns `201 Created`.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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 req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
@ -1664,7 +1699,7 @@ mod tests {
|
|
|
|
|
/// `POST /api/quotes/:id/report` with an unknown quote ID returns `404 Not Found`.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_report_quote_not_found() {
|
|
|
|
|
let app = router(MockRepo::empty());
|
|
|
|
|
let app = router(MockRepo::empty(), None);
|
|
|
|
|
let body = serde_json::json!({});
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
@ -1680,7 +1715,7 @@ mod tests {
|
|
|
|
|
/// returns `400 Bad Request`.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
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 body = serde_json::json!({ "reason": long_reason });
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
@ -1708,7 +1743,7 @@ mod tests {
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(first_body.to_string()))
|
|
|
|
|
.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;
|
|
|
|
|
assert_eq!(status, StatusCode::OK, "first reset must succeed");
|
|
|
|
|
|
|
|
|
|
@ -1721,7 +1756,7 @@ mod tests {
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(second_body.to_string()))
|
|
|
|
|
.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;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
status2,
|
|
|
|
|
@ -1738,7 +1773,7 @@ mod tests {
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(third_body.to_string()))
|
|
|
|
|
.unwrap();
|
|
|
|
|
let app3 = router(repo as Repo);
|
|
|
|
|
let app3 = router(repo as Repo, None);
|
|
|
|
|
let (status3, resp_body3) = send(app3, third_req).await;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
status3,
|
|
|
|
|
@ -1759,7 +1794,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_list_reports_correct_code_returns_200() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/admin/reports")
|
|
|
|
|
@ -1777,7 +1812,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_list_reports_missing_header_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/admin/reports")
|
|
|
|
|
@ -1791,7 +1826,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_list_reports_wrong_code_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("real-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/admin/reports")
|
|
|
|
|
@ -1826,7 +1861,7 @@ mod tests {
|
|
|
|
|
let quote_id = quotes[0].0.id.clone();
|
|
|
|
|
drop(quotes);
|
|
|
|
|
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo);
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri(format!("/api/admin/reports/{quote_id}"))
|
|
|
|
|
@ -1844,7 +1879,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_get_quote_reports_not_found_returns_404() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/admin/reports/nonexistent")
|
|
|
|
|
@ -1859,7 +1894,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_get_quote_reports_missing_header_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
.uri("/api/admin/reports/any-id")
|
|
|
|
|
@ -1891,7 +1926,7 @@ mod tests {
|
|
|
|
|
let quote_id = quotes[0].0.id.clone();
|
|
|
|
|
drop(quotes);
|
|
|
|
|
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo);
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
.uri(format!("/api/admin/reports/{quote_id}/quote"))
|
|
|
|
|
@ -1911,7 +1946,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_admin_delete_quote_not_found_returns_404() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
.uri("/api/admin/reports/nonexistent/quote")
|
|
|
|
|
@ -1927,7 +1962,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_admin_delete_quote_missing_header_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
.uri("/api/admin/reports/any-id/quote")
|
|
|
|
|
@ -1959,7 +1994,7 @@ mod tests {
|
|
|
|
|
let quote_id = quotes[0].0.id.clone();
|
|
|
|
|
drop(quotes);
|
|
|
|
|
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo);
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri(format!("/api/admin/reports/{quote_id}/hide"))
|
|
|
|
|
@ -1981,7 +2016,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_hide_quote_not_found_returns_404() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/admin/reports/nonexistent/hide")
|
|
|
|
|
@ -1997,7 +2032,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_hide_quote_missing_header_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/admin/reports/any-id/hide")
|
|
|
|
|
@ -2029,7 +2064,7 @@ mod tests {
|
|
|
|
|
let quote_id = quotes[0].0.id.clone();
|
|
|
|
|
drop(quotes);
|
|
|
|
|
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo);
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
.uri(format!("/api/admin/reports/{quote_id}/reports"))
|
|
|
|
|
@ -2045,7 +2080,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_clear_reports_not_found_returns_404() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
.uri("/api/admin/reports/nonexistent/reports")
|
|
|
|
|
@ -2061,7 +2096,7 @@ mod tests {
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_clear_reports_missing_header_returns_403() {
|
|
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
|
|
let app = router(repo);
|
|
|
|
|
let app = router(repo, None);
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
.uri("/api/admin/reports/any-id/reports")
|
|
|
|
|
@ -2070,6 +2105,42 @@ mod tests {
|
|
|
|
|
let (status, _) = send(app, req).await;
|
|
|
|
|
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) ─────────────────
|
|
|
|
|
@ -2111,7 +2182,7 @@ mod integration_tests {
|
|
|
|
|
.expect("failed to open test database");
|
|
|
|
|
repo.run_migrations().await.expect("migrations failed");
|
|
|
|
|
let repo: Arc<dyn QuoteRepository + Send + Sync> = Arc::new(repo);
|
|
|
|
|
(router(repo), f)
|
|
|
|
|
(router(repo, None), f)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Body helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
@ -2940,7 +3011,7 @@ mod integration_tests {
|
|
|
|
|
.await
|
|
|
|
|
.expect("failed to seed submissions lock");
|
|
|
|
|
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`.
|
|
|
|
|
|