Files
Mangalord/backend/tests/api_mangas_cover.rs
MechaCat02 8d34132883 bugfix: security & correctness bundle (0.34.1)
Five fixes bundled into one release:

- preserve user-attached tags across crawler upserts
  (repo::crawler::sync_tags now scopes to added_by IS NULL; orphaned
  attachments from deleted users are reaped as crawler-owned)
- gate manga PATCH and cover endpoints on uploaded_by (require_can_edit
  in api::mangas; non-NULL uploaded_by must match the caller)
- equalise login response time across user-existence branches
  (run argon2 against a OnceLock-cached dummy hash on the no-user
  branch so timing doesn't leak username existence)
- crawler download defences (SSRF allowlist of host literals
  including IPv4-mapped IPv6 ranges, 32 MiB streamed size cap,
  reject non-whitelisted image types, three-way chapter-probe
  classifier replaces the binary #avatar_menu check)
- tighten validation and clean up dead unload path
  (attach_tag + create_token enforce 64-char caps; LocalStorage
  rejects NUL bytes explicitly; reader flushFinalProgress drops
  the always-405 sendBeacon path)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:24:51 +02:00

463 lines
13 KiB
Rust

mod common;
use axum::http::StatusCode;
use serde_json::{json, Value};
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
use common::{
body_json, delete_with_cookie, fake_jpeg_bytes, fake_png_bytes, get, harness,
post_multipart_with_cookie, put_multipart, put_multipart_with_cookie, register_user,
MultipartBuilder,
};
async fn create_manga_with_cover(
app: &axum::Router,
cookie: &str,
title: &str,
cover: Option<(&str, &[u8])>,
) -> Value {
let mut form =
MultipartBuilder::new().add_json("metadata", json!({ "title": title }));
if let Some((ct, bytes)) = cover {
form = form.add_file("cover", "cover.bin", ct, bytes);
}
let resp = app
.clone()
.oneshot(post_multipart_with_cookie("/api/v1/mangas", form, cookie))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"seed create_manga failed: {:?}",
resp.status()
);
body_json(resp).await
}
fn id_of(body: &Value) -> Uuid {
Uuid::parse_str(body["id"].as_str().unwrap()).unwrap()
}
fn cover_form(bytes: &[u8]) -> MultipartBuilder {
MultipartBuilder::new().add_file("cover", "cover.bin", "application/octet-stream", bytes)
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_sets_path_when_none_existed(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(&h.app, &cookie, "Cover Me", None).await;
let id = id_of(&manga);
assert!(manga["cover_image_path"].is_null());
let bytes = fake_png_bytes();
let resp = h
.app
.clone()
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&bytes),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let expected_key = format!("mangas/{id}/cover.png");
assert_eq!(body["cover_image_path"], expected_key);
assert_eq!(body["title"], "Cover Me");
let file_resp = h
.app
.clone()
.oneshot(get(&format!("/api/v1/files/{expected_key}")))
.await
.unwrap();
assert_eq!(file_resp.status(), StatusCode::OK);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_replaces_existing_same_extension(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let original = fake_png_bytes();
let manga = create_manga_with_cover(
&h.app,
&cookie,
"Replace Me",
Some(("image/png", &original)),
)
.await;
let id = id_of(&manga);
let original_key = format!("mangas/{id}/cover.png");
assert_eq!(manga["cover_image_path"], original_key);
let mut replacement = fake_png_bytes();
replacement.extend_from_slice(b"-replacement-marker");
let resp = h
.app
.clone()
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&replacement),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["cover_image_path"], original_key);
let file_resp = h
.app
.clone()
.oneshot(get(&format!("/api/v1/files/{original_key}")))
.await
.unwrap();
assert_eq!(file_resp.status(), StatusCode::OK);
let body_bytes = http_body_util::BodyExt::collect(file_resp.into_body())
.await
.unwrap()
.to_bytes();
assert_eq!(body_bytes.as_ref(), replacement.as_slice());
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_replaces_existing_different_extension_and_deletes_old_blob(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let png = fake_png_bytes();
let manga = create_manga_with_cover(
&h.app,
&cookie,
"Switch Ext",
Some(("image/png", &png)),
)
.await;
let id = id_of(&manga);
let old_key = format!("mangas/{id}/cover.png");
assert_eq!(manga["cover_image_path"], old_key);
let jpeg = fake_jpeg_bytes();
let resp = h
.app
.clone()
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&jpeg),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let new_key = format!("mangas/{id}/cover.jpg");
assert_eq!(body["cover_image_path"], new_key);
let new_file = h
.app
.clone()
.oneshot(get(&format!("/api/v1/files/{new_key}")))
.await
.unwrap();
assert_eq!(new_file.status(), StatusCode::OK);
let old_file = h
.app
.clone()
.oneshot(get(&format!("/api/v1/files/{old_key}")))
.await
.unwrap();
assert_eq!(old_file.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_rejects_unauthenticated(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(&h.app, &cookie, "Public Read", None).await;
let id = id_of(&manga);
let resp = h
.app
.clone()
.oneshot(put_multipart(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&fake_png_bytes()),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_404_on_unknown_id(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let id = Uuid::new_v4();
let resp = h
.app
.clone()
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&fake_png_bytes()),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_rejects_non_image_with_unsupported_media_type(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(&h.app, &cookie, "Not Image", None).await;
let id = id_of(&manga);
let pdf = b"%PDF-1.4\n%\xc4\xe5".to_vec();
let resp = h
.app
.clone()
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&pdf),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
let body = body_json(resp).await;
assert_eq!(body["error"]["code"], "unsupported_media_type");
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_rejects_oversized(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(&h.app, &cookie, "Too Big", None).await;
let id = id_of(&manga);
// Harness max_file_bytes is 256 KiB; 300 KiB trips the cap.
let mut bytes = fake_png_bytes();
bytes.resize(300 * 1024, 0);
let resp = h
.app
.clone()
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&bytes),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_rejects_missing_cover_part(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(&h.app, &cookie, "Empty Form", None).await;
let id = id_of(&manga);
let resp = h
.app
.clone()
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
MultipartBuilder::new(),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = body_json(resp).await;
assert_eq!(body["error"]["code"], "validation_failed");
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_preserves_other_metadata(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(
&h.app,
&cookie,
"Keep My Fields",
None,
)
.await;
let id = id_of(&manga);
let resp = h
.app
.clone()
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&fake_png_bytes()),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["title"], "Keep My Fields");
assert_eq!(body["status"], "ongoing");
assert_eq!(body["authors"], json!([]));
assert_eq!(body["genres"], json!([]));
assert_eq!(body["tags"], json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_cover_clears_path_and_removes_blob(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let png = fake_png_bytes();
let manga = create_manga_with_cover(
&h.app,
&cookie,
"Bye Cover",
Some(("image/png", &png)),
)
.await;
let id = id_of(&manga);
let key = format!("mangas/{id}/cover.png");
let resp = h
.app
.clone()
.oneshot(delete_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert!(body["cover_image_path"].is_null());
assert_eq!(body["title"], "Bye Cover");
let file_resp = h
.app
.clone()
.oneshot(get(&format!("/api/v1/files/{key}")))
.await
.unwrap();
assert_eq!(file_resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_cover_is_idempotent_when_no_cover_present(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(&h.app, &cookie, "Never Had One", None).await;
let id = id_of(&manga);
for _ in 0..2 {
let resp = h
.app
.clone()
.oneshot(delete_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert!(body["cover_image_path"].is_null());
}
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_cover_rejects_unauthenticated(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(&h.app, &cookie, "Locked", None).await;
let id = id_of(&manga);
let resp = h
.app
.clone()
.oneshot(
axum::http::Request::builder()
.method("DELETE")
.uri(format!("/api/v1/mangas/{id}/cover"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_cover_404_on_unknown_id(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let id = Uuid::new_v4();
let resp = h
.app
.clone()
.oneshot(delete_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
/// Authz: PUT /mangas/:id/cover must be uploader-only.
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_forbidden_for_non_uploader(pool: PgPool) {
let h = harness(pool);
let (_, owner_cookie) = register_user(&h.app).await;
let (_, intruder_cookie) = register_user(&h.app).await;
let manga =
create_manga_with_cover(&h.app, &owner_cookie, "Mine", None).await;
let id = id_of(&manga);
let resp = h
.app
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&fake_png_bytes()),
&intruder_cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
/// Authz: DELETE /mangas/:id/cover must be uploader-only.
#[sqlx::test(migrations = "./migrations")]
async fn delete_cover_forbidden_for_non_uploader(pool: PgPool) {
let h = harness(pool);
let (_, owner_cookie) = register_user(&h.app).await;
let (_, intruder_cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(
&h.app,
&owner_cookie,
"Mine",
Some(("image/jpeg", &fake_jpeg_bytes())),
)
.await;
let id = id_of(&manga);
let resp = h
.app
.oneshot(delete_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
&intruder_cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}