diff --git a/backend/src/api/chapters.rs b/backend/src/api/chapters.rs index 6a514de..de01895 100644 --- a/backend/src/api/chapters.rs +++ b/backend/src/api/chapters.rs @@ -69,6 +69,21 @@ async fn get_one( Ok(Json(chapter)) } +/// Add a chapter to a manga. +/// +/// **Authorization is intentionally open**: any authenticated principal — +/// a browser session *or* a bot API token — may add a chapter to *any* +/// manga, including crawler-imported rows. This is by design: chapters are +/// treated as community contributions, unlike the manga record itself +/// (title/cover/metadata), whose edits gate through `require_can_edit` +/// (see [`crate::api::mangas`]). The `CurrentUser` binding still requires a +/// valid identity, so contributions are attributable, just not owner-scoped. +/// +/// This contract is locked by `tests/api_chapters.rs` +/// (`non_owner_can_upload_chapter`); changing it to owner-only is a +/// deliberate decision, not a drive-by tightening. Until a richer +/// contributor/moderation model lands this is acknowledged, intended +/// behaviour — see the auth note in CLAUDE.md. async fn create( State(state): State, CurrentUser(user): CurrentUser, diff --git a/backend/tests/api_chapters.rs b/backend/tests/api_chapters.rs index 4b7b978..6267b8e 100644 --- a/backend/tests/api_chapters.rs +++ b/backend/tests/api_chapters.rs @@ -8,6 +8,8 @@ use uuid::Uuid; #[allow(unused_imports)] use serde_json as _; +use common::MultipartBuilder; + async fn seed_manga(h: &common::Harness, cookie: &str, title: &str) -> Uuid { common::seed_manga_via_api(&h.app, cookie, title).await } @@ -272,3 +274,44 @@ async fn list_pages_returns_404_for_unknown_chapter(pool: PgPool) { .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +#[sqlx::test(migrations = "./migrations")] +async fn non_owner_can_upload_chapter(pool: PgPool) { + // Contract lock: chapter upload is INTENTIONALLY open to any + // authenticated user, not just the manga's creator. A user who did not + // create the manga can still contribute a chapter (community + // contributions), unlike manga-record edits which gate on ownership. + // If this ever needs to become owner-only, that's a deliberate change — + // this test (and the doc comment on `api::chapters::create`) should be + // updated together, not silently broken. + let h = common::harness(pool); + + // Owner creates the manga. + let (_owner, owner_cookie) = common::register_user(&h.app).await; + let manga_id = seed_manga(&h, &owner_cookie, "Berserk").await; + + // A different, non-owner user uploads a chapter to it. + let (_other, other_cookie) = common::register_user(&h.app).await; + let resp = h + .app + .clone() + .oneshot(common::post_multipart_with_cookie( + &format!("/api/v1/mangas/{manga_id}/chapters"), + MultipartBuilder::new() + .add_json("metadata", json!({ "number": 1, "title": "Contributed" })) + .add_file("page", "1.png", "image/png", &common::fake_png_bytes()), + &other_cookie, + )) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::CREATED, + "a non-owner authenticated user must be able to upload a chapter" + ); + let body = common::body_json(resp).await; + assert_eq!(body["number"], 1); + assert_eq!(body["title"], "Contributed"); + assert_eq!(body["page_count"], 1); +}