Addresses the security-audit findings on top of the admin feature stack: M1: /admin/mangas/:id/chapters now paginates (default limit 200, max 500). A long-runner with thousands of chapters would otherwise produce a multi-MB response with that many scalar subqueries per row — admin-only but a real stall risk on one expand-click. Adds explicit pagination tests for the cap and offset; frontend renders a "Showing first N of M" hint when the cap clips the result. L1: repo::user::set_is_admin renamed to set_is_admin_unchecked with a doc-comment pointing at admin_safe_set_is_admin for production use. The short name was a footgun — a future contributor reaching for it would silently bypass self-protection, the last-admin invariant, and the audit log. Used only by integration-test setup; production code goes through the admin_safe_* paths. CSRF posture: build_session_cookie carries a comment that the SameSite=Lax default is the project's CSRF defense for state-changing mutations and breaks the instant anyone adds a side-effecting GET under /admin/*. Spells out what to do then (Strict + explicit token check). Test counts: 43 backend admin tests + 12 vitest admin tests all green; svelte-check 0/0 across 446 files.
549 lines
17 KiB
Rust
549 lines
17 KiB
Rust
//! PR 3 (feat/admin-mangas-api) integration tests.
|
|
//!
|
|
//! Per-variant fixture tests for the derived sync-state SQL plus
|
|
//! happy-path E2E for the two admin endpoints. Auth-gate regression
|
|
//! (403/401) is covered by PR 1's `RequireAdmin` test matrix; the only
|
|
//! gate test here is one spot check per endpoint.
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use axum::Router;
|
|
use serde_json::json;
|
|
use sqlx::PgPool;
|
|
use tower::ServiceExt;
|
|
use uuid::Uuid;
|
|
|
|
use mangalord::repo;
|
|
|
|
const SOURCE_ID: &str = "test-source";
|
|
|
|
async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String) {
|
|
let (username, cookie) = common::register_user(app).await;
|
|
let u = repo::user::find_by_username(pool, &username)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap();
|
|
(username, cookie)
|
|
}
|
|
|
|
async fn seed_source(pool: &PgPool) {
|
|
repo::crawler::ensure_source(pool, SOURCE_ID, "Test", "https://example.test")
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
async fn insert_manga(pool: &PgPool, title: &str) -> Uuid {
|
|
let (id,): (Uuid,) = sqlx::query_as(
|
|
"INSERT INTO mangas (title, status, alt_titles) VALUES ($1, 'ongoing', ARRAY[]::text[]) RETURNING id",
|
|
)
|
|
.bind(title)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
async fn insert_manga_source(
|
|
pool: &PgPool,
|
|
manga_id: Uuid,
|
|
source_manga_key: &str,
|
|
dropped: bool,
|
|
) {
|
|
let dropped_at = if dropped { "now()" } else { "NULL" };
|
|
let sql = format!(
|
|
"INSERT INTO manga_sources (source_id, source_manga_key, manga_id, source_url, dropped_at) \
|
|
VALUES ($1, $2, $3, 'https://example.test/m', {dropped_at})"
|
|
);
|
|
sqlx::query(&sql)
|
|
.bind(SOURCE_ID)
|
|
.bind(source_manga_key)
|
|
.bind(manga_id)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
async fn insert_chapter(pool: &PgPool, manga_id: Uuid, number: i32, page_count: i32) -> Uuid {
|
|
let (id,): (Uuid,) = sqlx::query_as(
|
|
"INSERT INTO chapters (manga_id, number, title, page_count) VALUES ($1, $2, NULL, $3) RETURNING id",
|
|
)
|
|
.bind(manga_id)
|
|
.bind(number)
|
|
.bind(page_count)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
async fn insert_chapter_source(
|
|
pool: &PgPool,
|
|
chapter_id: Uuid,
|
|
source_chapter_key: &str,
|
|
dropped: bool,
|
|
) {
|
|
let dropped_at = if dropped { "now()" } else { "NULL" };
|
|
let sql = format!(
|
|
"INSERT INTO chapter_sources (source_id, source_chapter_key, chapter_id, source_url, dropped_at) \
|
|
VALUES ($1, $2, $3, 'https://example.test/c', {dropped_at})"
|
|
);
|
|
sqlx::query(&sql)
|
|
.bind(SOURCE_ID)
|
|
.bind(source_chapter_key)
|
|
.bind(chapter_id)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
async fn insert_job(pool: &PgPool, payload: serde_json::Value, state: &str) {
|
|
sqlx::query("INSERT INTO crawler_jobs (payload, state) VALUES ($1, $2)")
|
|
.bind(payload)
|
|
.bind(state)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// Per-variant tests don't care about pagination — fetch the whole
|
|
/// chapter set (up to the hard cap) and discard the total.
|
|
async fn fetch_chapter_rows(
|
|
pool: &PgPool,
|
|
manga_id: Uuid,
|
|
) -> Vec<mangalord::repo::admin_view::AdminChapterRow> {
|
|
let (rows, _) = repo::admin_view::list_chapters_with_sync_state(
|
|
pool,
|
|
&repo::admin_view::ListAdminChaptersQuery {
|
|
manga_id,
|
|
limit: 500,
|
|
offset: 0,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
rows
|
|
}
|
|
|
|
// ---- manga sync state ------------------------------------------------------
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn manga_state_synced_for_fresh_source(pool: PgPool) {
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "Synced Manga").await;
|
|
insert_manga_source(&pool, m, "smk-1", false).await;
|
|
|
|
let (rows, total) = repo::admin_view::list_mangas_with_sync_state(
|
|
&pool,
|
|
&repo::admin_view::ListAdminMangasQuery {
|
|
limit: 50,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(total, 1);
|
|
assert_eq!(rows[0].id, m);
|
|
assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::Synced);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn manga_state_synced_for_user_upload_without_sources(pool: PgPool) {
|
|
let m = insert_manga(&pool, "User Upload").await;
|
|
let (rows, _) = repo::admin_view::list_mangas_with_sync_state(
|
|
&pool,
|
|
&repo::admin_view::ListAdminMangasQuery {
|
|
limit: 50,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rows[0].id, m);
|
|
assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::Synced);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn manga_state_dropped_when_all_sources_dropped(pool: PgPool) {
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "Dropped Manga").await;
|
|
insert_manga_source(&pool, m, "smk-1", true).await;
|
|
|
|
let (rows, _) = repo::admin_view::list_mangas_with_sync_state(
|
|
&pool,
|
|
&repo::admin_view::ListAdminMangasQuery {
|
|
limit: 50,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rows[0].id, m);
|
|
assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::Dropped);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn manga_state_in_progress_via_sync_chapter_list_job(pool: PgPool) {
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "Syncing Manga").await;
|
|
insert_manga_source(&pool, m, "smk-1", false).await;
|
|
// sync_chapter_list payload carries manga_id directly.
|
|
insert_job(
|
|
&pool,
|
|
json!({
|
|
"kind": "sync_chapter_list",
|
|
"source_id": SOURCE_ID,
|
|
"manga_id": m.to_string(),
|
|
"source_manga_key": "smk-1",
|
|
}),
|
|
"pending",
|
|
)
|
|
.await;
|
|
|
|
let (rows, _) = repo::admin_view::list_mangas_with_sync_state(
|
|
&pool,
|
|
&repo::admin_view::ListAdminMangasQuery {
|
|
limit: 50,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::InProgress);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn manga_state_in_progress_via_sync_manga_job(pool: PgPool) {
|
|
// The trickier branch: sync_manga payload is keyed by
|
|
// source_manga_key, NOT manga_id — must join through manga_sources.
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "Metadata-Refreshing Manga").await;
|
|
insert_manga_source(&pool, m, "smk-key-42", false).await;
|
|
insert_job(
|
|
&pool,
|
|
json!({
|
|
"kind": "sync_manga",
|
|
"source_id": SOURCE_ID,
|
|
"source_manga_key": "smk-key-42",
|
|
}),
|
|
"running",
|
|
)
|
|
.await;
|
|
|
|
let (rows, _) = repo::admin_view::list_mangas_with_sync_state(
|
|
&pool,
|
|
&repo::admin_view::ListAdminMangasQuery {
|
|
limit: 50,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::InProgress);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn manga_list_filters_by_sync_state(pool: PgPool) {
|
|
seed_source(&pool).await;
|
|
let m_synced = insert_manga(&pool, "AAA Synced").await;
|
|
insert_manga_source(&pool, m_synced, "smk-a", false).await;
|
|
let m_dropped = insert_manga(&pool, "BBB Dropped").await;
|
|
insert_manga_source(&pool, m_dropped, "smk-b", true).await;
|
|
|
|
let (rows, total) = repo::admin_view::list_mangas_with_sync_state(
|
|
&pool,
|
|
&repo::admin_view::ListAdminMangasQuery {
|
|
sync_state: Some(mangalord::domain::MangaSyncState::Dropped),
|
|
limit: 50,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(total, 1);
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].id, m_dropped);
|
|
}
|
|
|
|
// ---- chapter sync state ----------------------------------------------------
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn chapter_state_synced_when_pages_present(pool: PgPool) {
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "M").await;
|
|
insert_manga_source(&pool, m, "smk", false).await;
|
|
let c = insert_chapter(&pool, m, 1, 12).await;
|
|
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
|
|
|
let rows = fetch_chapter_rows(&pool, m).await;
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].id, c);
|
|
assert_eq!(rows[0].sync_state, mangalord::domain::ChapterSyncState::Synced);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn chapter_state_not_downloaded_when_page_count_zero(pool: PgPool) {
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "M").await;
|
|
let c = insert_chapter(&pool, m, 1, 0).await;
|
|
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
|
|
|
let rows = fetch_chapter_rows(&pool, m).await;
|
|
assert_eq!(
|
|
rows[0].sync_state,
|
|
mangalord::domain::ChapterSyncState::NotDownloaded
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn chapter_state_downloading_when_job_in_flight(pool: PgPool) {
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "M").await;
|
|
let c = insert_chapter(&pool, m, 1, 0).await;
|
|
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
|
insert_job(
|
|
&pool,
|
|
json!({
|
|
"kind": "sync_chapter_content",
|
|
"source_id": SOURCE_ID,
|
|
"chapter_id": c.to_string(),
|
|
"source_chapter_key": "ckey-1",
|
|
}),
|
|
"running",
|
|
)
|
|
.await;
|
|
|
|
let rows = fetch_chapter_rows(&pool, m).await;
|
|
assert_eq!(
|
|
rows[0].sync_state,
|
|
mangalord::domain::ChapterSyncState::Downloading
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn chapter_state_dropped_when_all_sources_dropped(pool: PgPool) {
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "M").await;
|
|
let c = insert_chapter(&pool, m, 1, 0).await;
|
|
insert_chapter_source(&pool, c, "ckey-1", true).await;
|
|
|
|
let rows = fetch_chapter_rows(&pool, m).await;
|
|
assert_eq!(
|
|
rows[0].sync_state,
|
|
mangalord::domain::ChapterSyncState::Dropped
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn chapter_state_failed_when_most_recent_job_dead(pool: PgPool) {
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "M").await;
|
|
let c = insert_chapter(&pool, m, 1, 0).await;
|
|
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
|
insert_job(
|
|
&pool,
|
|
json!({
|
|
"kind": "sync_chapter_content",
|
|
"source_id": SOURCE_ID,
|
|
"chapter_id": c.to_string(),
|
|
"source_chapter_key": "ckey-1",
|
|
}),
|
|
"dead",
|
|
)
|
|
.await;
|
|
|
|
let rows = fetch_chapter_rows(&pool, m).await;
|
|
assert_eq!(
|
|
rows[0].sync_state,
|
|
mangalord::domain::ChapterSyncState::Failed
|
|
);
|
|
}
|
|
|
|
// ---- HTTP-level happy-path + gate ------------------------------------------
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn http_list_mangas_returns_paged_with_state(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_admin, cookie) = seed_admin(&pool, &h.app).await;
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "Hello").await;
|
|
insert_manga_source(&pool, m, "smk", false).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/mangas?limit=50",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
let items = body["items"].as_array().unwrap();
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["id"], m.to_string());
|
|
assert_eq!(items[0]["sync_state"], "synced");
|
|
assert_eq!(items[0]["chapter_count"], 0);
|
|
assert_eq!(body["page"]["total"], 1);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn http_list_mangas_rejects_unknown_sync_state(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_admin, cookie) = seed_admin(&pool, &h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/mangas?sync_state=bogus",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn http_list_chapters_returns_per_chapter_state(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_admin, cookie) = seed_admin(&pool, &h.app).await;
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "M").await;
|
|
let c1 = insert_chapter(&pool, m, 1, 12).await;
|
|
let c2 = insert_chapter(&pool, m, 2, 0).await;
|
|
insert_chapter_source(&pool, c1, "ck1", false).await;
|
|
insert_chapter_source(&pool, c2, "ck2", false).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie(
|
|
&format!("/api/v1/admin/mangas/{m}/chapters"),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
let items = body["items"].as_array().unwrap();
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0]["id"], c1.to_string());
|
|
assert_eq!(items[0]["sync_state"], "synced");
|
|
assert_eq!(items[1]["id"], c2.to_string());
|
|
assert_eq!(items[1]["sync_state"], "not_downloaded");
|
|
assert_eq!(body["page"]["total"], 2);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn http_list_chapters_caps_limit_at_500(pool: PgPool) {
|
|
// The handler clamps limit to [1, 500] so a long-runner with
|
|
// thousands of chapters can't be turned into a request-stall by an
|
|
// admin (or by a curious admin tab) just clicking expand.
|
|
let h = common::harness(pool.clone());
|
|
let (_admin, cookie) = seed_admin(&pool, &h.app).await;
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "M").await;
|
|
for n in 1..=3 {
|
|
let _c = insert_chapter(&pool, m, n, 0).await;
|
|
}
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie(
|
|
&format!("/api/v1/admin/mangas/{m}/chapters?limit=999"),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["page"]["limit"], 500, "limit must clamp to 500");
|
|
assert_eq!(body["items"].as_array().unwrap().len(), 3);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn http_list_chapters_paginates(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_admin, cookie) = seed_admin(&pool, &h.app).await;
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "M").await;
|
|
for n in 1..=5 {
|
|
let _c = insert_chapter(&pool, m, n, 0).await;
|
|
}
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
&format!("/api/v1/admin/mangas/{m}/chapters?limit=2&offset=2"),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
let items = body["items"].as_array().unwrap();
|
|
assert_eq!(items.len(), 2);
|
|
// Ordered by chapter number ascending; offset=2 skips chapters 1 & 2.
|
|
assert_eq!(items[0]["number"], 3);
|
|
assert_eq!(items[1]["number"], 4);
|
|
assert_eq!(body["page"]["total"], 5);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn http_list_chapters_returns_404_for_unknown_manga(pool: PgPool) {
|
|
// Regression: used to return 200 [] for a non-existent manga,
|
|
// which silently rendered "No chapters." for a typo'd / deleted id.
|
|
let h = common::harness(pool.clone());
|
|
let (_admin, cookie) = seed_admin(&pool, &h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie(
|
|
&format!("/api/v1/admin/mangas/{}/chapters", Uuid::new_v4()),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn chapter_state_synced_when_pages_present_even_with_dead_job(pool: PgPool) {
|
|
// Regression: the old CASE prioritised the dead-job branch above
|
|
// the page_count check, so a chapter with pages on disk AND a
|
|
// historical dead job (e.g. from a re-download attempt that
|
|
// crashed) flipped to Failed — contradicting Synced's "downloaded
|
|
// at some point" contract.
|
|
seed_source(&pool).await;
|
|
let m = insert_manga(&pool, "M").await;
|
|
let c = insert_chapter(&pool, m, 1, 12).await; // pages present
|
|
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
|
insert_job(
|
|
&pool,
|
|
json!({
|
|
"kind": "sync_chapter_content",
|
|
"source_id": SOURCE_ID,
|
|
"chapter_id": c.to_string(),
|
|
"source_chapter_key": "ckey-1",
|
|
}),
|
|
"dead",
|
|
)
|
|
.await;
|
|
|
|
let rows = fetch_chapter_rows(&pool, m).await;
|
|
assert_eq!(
|
|
rows[0].sync_state,
|
|
mangalord::domain::ChapterSyncState::Synced,
|
|
"pages on disk override historical dead-job noise"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn http_list_mangas_requires_admin(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_u, cookie) = common::register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/admin/mangas", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|