diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8cf1648..af18438 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.128.16" +version = "0.128.17" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c8efba6..653dd06 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.128.16" +version = "0.128.17" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/admin/analysis.rs b/backend/src/api/admin/analysis.rs index 43b62b6..de0571d 100644 --- a/backend/src/api/admin/analysis.rs +++ b/backend/src/api/admin/analysis.rs @@ -328,10 +328,21 @@ fn ensure_enabled(state: &AppState) -> AppResult<()> { async fn reenqueue( State(state): State, admin: RequireAdmin, - body: Option>, + raw: axum::body::Bytes, ) -> AppResult> { ensure_enabled(&state)?; - let body = body.map(|b| b.0).unwrap_or_default(); + // An ABSENT/empty body means the default "All" scope. A PRESENT body must be + // valid JSON — `Option>` would silently collapse a malformed body to + // None and run the full-library default the caller never intended, so parse + // the raw bytes ourselves and 422 on a real parse error. + let body: ReenqueueBody = if raw.iter().all(u8::is_ascii_whitespace) { + ReenqueueBody::default() + } else { + serde_json::from_slice(&raw).map_err(|e| AppError::ValidationFailed { + message: "reenqueue body is not valid JSON".into(), + details: json!({ "body": e.to_string() }), + })? + }; // Resolve the scope. manga_id and chapter_id are mutually exclusive; // an unknown target is a 404 rather than a silent zero-enqueue. diff --git a/backend/tests/api_admin_analysis.rs b/backend/tests/api_admin_analysis.rs index a58d3a2..a5d1079 100644 --- a/backend/tests/api_admin_analysis.rs +++ b/backend/tests/api_admin_analysis.rs @@ -189,6 +189,51 @@ async fn reenqueue_returns_503_when_analysis_disabled(pool: PgPool) { assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); } +#[sqlx::test(migrations = "./migrations")] +async fn reenqueue_rejects_a_malformed_body(pool: PgPool) { + // A present-but-malformed JSON body must be a 422 — NOT silently coerced to + // an absent body, which would run the default full "All" scope the caller + // never asked for and enqueue jobs across the whole library. + let h = common::harness_with_analysis(pool.clone()); + let cookie = seed_admin(&pool, &h.app).await; + seed_pages(&pool, 2).await; + + let resp = h + .app + .clone() + .oneshot(common::post_raw_with_cookie( + "/api/v1/admin/analysis/reenqueue", + "{ not valid json", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(analyze_job_count(&pool).await, 0, "a rejected body enqueues nothing"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn reenqueue_treats_empty_body_as_default_all_scope(pool: PgPool) { + // An absent/empty body is still valid: it means the default "All" scope. + let h = common::harness_with_analysis(pool.clone()); + let cookie = seed_admin(&pool, &h.app).await; + seed_pages(&pool, 2).await; + + let resp = h + .app + .clone() + .oneshot(common::post_raw_with_cookie( + "/api/v1/admin/analysis/reenqueue", + "", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["enqueued"], 2, "empty body backfills the whole library"); +} + #[sqlx::test(migrations = "./migrations")] async fn reenqueue_backfills_existing_pages(pool: PgPool) { let h = common::harness_with_analysis(pool.clone()); diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index b9a6945..e77e3c4 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -496,6 +496,20 @@ pub fn post_json_with_cookie( .unwrap() } +/// Like [`post_json_with_cookie`] but sends a raw (possibly malformed) body, +/// for exercising body-parse error handling. Content-Type stays JSON and the +/// CSRF Origin is attached so the request reaches the handler. +pub fn post_raw_with_cookie(uri: &str, body: &str, cookie: &str) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .header(header::COOKIE, cookie) + .header(header::ORIGIN, TEST_ORIGIN) + .body(Body::from(body.to_string())) + .unwrap() +} + /// Same as [`post_json_with_cookie`] but also attaches `Origin` (and /// optionally `Referer`) headers. Used by the admin CSRF tests to drive /// the cross-origin reject + allowed-origin accept paths. diff --git a/frontend/package.json b/frontend/package.json index 46afaed..392af2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.128.16", + "version": "0.128.17", "private": true, "type": "module", "scripts": {