Files
Mangalord/backend/tests/api_bookmarks.rs
MechaCat02 785b9755cf bugfix: case-insensitive usernames, reject non-positive bookmark page
Two related correctness fixes from the audit:

- Username uniqueness was case-sensitive (`username text UNIQUE`), so
  "Alice" and "alice" could both register and then race on login.
  Migration 0006 adds a unique index on `lower(username)`; the
  existing constraint is kept (overlapping but cheap) to avoid a
  destructive migration on any deployments that may already exist.
  `repo::user::find_by_username` now matches on `lower(username) =
  lower($1)` so login is case-insensitive against the same index.
  Test: registering "alice" then "Alice" returns 409 conflict; login
  with "ALICE" succeeds against the existing user.

- `POST /api/v1/bookmarks` silently accepted `page: 0` and `page: -1`
  even though both are nonsense for a 1-indexed page number. Reject
  with 422 `validation_failed` and `details.page` populated, matching
  the pattern used for missing-metadata / empty-title elsewhere. Test
  covers both 0 and -1.

Lockstep version bump to 0.9.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:27:19 +02:00

348 lines
11 KiB
Rust

mod common;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
#[sqlx::test(migrations = "./migrations")]
async fn create_then_list_returns_only_own(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie_a) = common::register_user(&h.app).await;
let (_, cookie_b) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await;
// User A bookmarks the manga.
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string() }),
&cookie_a,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = common::body_json(resp).await;
assert_eq!(body["manga_id"], manga_id.to_string());
// User B sees nothing.
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_b))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["items"], json!([]));
// User A sees their bookmark.
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_a))
.await
.unwrap();
let body = common::body_json(resp).await;
let items = body["items"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["manga_id"], manga_id.to_string());
}
#[sqlx::test(migrations = "./migrations")]
async fn create_returns_409_on_duplicate_manga_level(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let make = || {
common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string() }),
&cookie,
)
};
let first = h.app.clone().oneshot(make()).await.unwrap();
assert_eq!(first.status(), StatusCode::CREATED);
let second = h.app.oneshot(make()).await.unwrap();
assert_eq!(second.status(), StatusCode::CONFLICT);
let body = common::body_json(second).await;
assert_eq!(body["error"]["code"], "conflict");
}
#[sqlx::test(migrations = "./migrations")]
async fn create_404_on_unknown_manga(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let unknown = Uuid::nil();
let resp = h
.app
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": unknown.to_string() }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn create_rejects_non_positive_page_with_422(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string(), "page": 0 }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "validation_failed");
assert!(body["error"]["details"]["page"].is_string());
let resp = h
.app
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string(), "page": -1 }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "./migrations")]
async fn create_requires_authentication(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
// Unauthenticated request → 401.
let resp = h
.app
.oneshot(common::post_json(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string() }),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn user_a_cannot_delete_user_b_bookmark(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie_a) = common::register_user(&h.app).await;
let (_, cookie_b) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await;
// User A creates a bookmark.
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string() }),
&cookie_a,
))
.await
.unwrap();
let body = common::body_json(resp).await;
let id = body["id"].as_str().unwrap().to_string();
// User B tries to delete → 403.
let resp = h
.app
.clone()
.oneshot(common::delete_with_cookie(
&format!("/api/v1/bookmarks/{id}"),
&cookie_b,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "forbidden");
// User A succeeds.
let resp = h
.app
.oneshot(common::delete_with_cookie(
&format!("/api/v1/bookmarks/{id}"),
&cookie_a,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_unknown_bookmark_is_404(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::delete_with_cookie(
"/api/v1/bookmarks/00000000-0000-0000-0000-000000000000",
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_already_deleted_bookmark_is_404(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string() }),
&cookie,
))
.await
.unwrap();
let id = common::body_json(resp).await["id"].as_str().unwrap().to_string();
let resp = h
.app
.clone()
.oneshot(common::delete_with_cookie(
&format!("/api/v1/bookmarks/{id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Deleting again → 404, not 500.
let resp = h
.app
.oneshot(common::delete_with_cookie(
&format!("/api/v1/bookmarks/{id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_me_requires_authentication(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.oneshot(common::get("/api/v1/me/bookmarks"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_me_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
// Seed a chapter directly so we know its number without uploading pages.
mangalord::repo::chapter::create(&pool, manga_id, 7, Some("The Brand"))
.await
.unwrap();
// Look up its id so we can bookmark it.
let chapter_id: Uuid = sqlx::query_scalar(
"SELECT id FROM chapters WHERE manga_id = $1 AND number = $2",
)
.bind(manga_id)
.bind(7_i32)
.fetch_one(&pool)
.await
.unwrap();
// Bookmark the chapter at page 4.
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({
"manga_id": manga_id.to_string(),
"chapter_id": chapter_id.to_string(),
"page": 4
}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
// Also bookmark the manga at the manga level — chapter_number must
// come back null for it, populated for the chapter one.
let _ = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string() }),
&cookie,
))
.await
.unwrap();
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
let items = body["items"].as_array().unwrap();
assert_eq!(items.len(), 2);
let chapter_bookmark = items
.iter()
.find(|b| !b["chapter_id"].is_null())
.expect("chapter-level bookmark present");
assert_eq!(chapter_bookmark["chapter_number"], 7);
assert_eq!(chapter_bookmark["page"], 4);
let manga_bookmark = items
.iter()
.find(|b| b["chapter_id"].is_null())
.expect("manga-level bookmark present");
assert!(
manga_bookmark["chapter_number"].is_null(),
"manga-level bookmark must have a null chapter_number"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_me_returns_paged_envelope(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert!(body["items"].is_array());
assert_eq!(body["page"]["limit"], 50);
assert_eq!(body["page"]["offset"], 0);
assert!(body["page"]["total"].is_null());
}