Files
Mangalord/backend/tests/api_auth.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

309 lines
9.0 KiB
Rust

mod common;
use axum::http::{header, StatusCode};
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
fn creds(username: &str) -> serde_json::Value {
json!({ "username": username, "password": "hunter2hunter2" })
}
#[sqlx::test(migrations = "./migrations")]
async fn register_creates_user_and_sets_session_cookie(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.oneshot(common::post_json(
"/api/v1/auth/register",
creds("alice"),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let cookie_header = resp
.headers()
.get(header::SET_COOKIE)
.expect("Set-Cookie present")
.to_str()
.unwrap()
.to_string();
assert!(cookie_header.starts_with("mangalord_session="));
assert!(cookie_header.contains("HttpOnly"));
assert!(cookie_header.contains("SameSite=Lax"));
assert!(cookie_header.contains("Path=/"));
// In the test harness cookie_secure is false; production has Secure.
assert!(!cookie_header.contains("Secure"));
let body = common::body_json(resp).await;
assert_eq!(body["user"]["username"], "alice");
assert!(body["user"]["id"].as_str().is_some());
assert!(
body["user"].get("password_hash").is_none(),
"password_hash must never leak to the API"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn register_rejects_duplicate_username_with_conflict(pool: PgPool) {
let h = common::harness(pool);
let _ = h
.app
.clone()
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
.await
.unwrap();
let resp = h
.app
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "conflict");
}
#[sqlx::test(migrations = "./migrations")]
async fn register_rejects_case_only_username_collisions(pool: PgPool) {
let h = common::harness(pool);
let _ = h
.app
.clone()
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
.await
.unwrap();
// Mixed-case variant collides via the lower(username) index.
let resp = h
.app
.clone()
.oneshot(common::post_json("/api/v1/auth/register", creds("Alice")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
// Login with either casing finds the same user.
let resp = h
.app
.oneshot(common::post_json("/api/v1/auth/login", creds("ALICE")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[sqlx::test(migrations = "./migrations")]
async fn register_rejects_short_password(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.oneshot(common::post_json(
"/api/v1/auth/register",
json!({ "username": "alice", "password": "short" }),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "invalid_input");
}
#[sqlx::test(migrations = "./migrations")]
async fn login_succeeds_and_rotates_session(pool: PgPool) {
let h = common::harness(pool);
let _ = h
.app
.clone()
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
.await
.unwrap();
let resp = h
.app
.oneshot(common::post_json("/api/v1/auth/login", creds("alice")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let cookie = common::extract_session_cookie(&resp).expect("login sets a cookie");
assert!(cookie.starts_with("mangalord_session="));
}
#[sqlx::test(migrations = "./migrations")]
async fn login_rejects_wrong_password(pool: PgPool) {
let h = common::harness(pool);
let _ = h
.app
.clone()
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
.await
.unwrap();
let resp = h
.app
.oneshot(common::post_json(
"/api/v1/auth/login",
json!({ "username": "alice", "password": "wrongpassword" }),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "unauthenticated");
}
#[sqlx::test(migrations = "./migrations")]
async fn login_rejects_unknown_user(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.oneshot(common::post_json("/api/v1/auth/login", creds("ghost")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn me_returns_user_with_valid_cookie(pool: PgPool) {
let h = common::harness(pool);
let (username, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["user"]["username"], username);
}
#[sqlx::test(migrations = "./migrations")]
async fn me_returns_401_without_cookie(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.oneshot(common::get("/api/v1/auth/me"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn logout_clears_session(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/auth/logout",
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Same cookie no longer works.
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn create_and_use_bot_token(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/auth/tokens",
json!({ "name": "ci-bot" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = common::body_json(resp).await;
assert_eq!(body["name"], "ci-bot");
let bearer = body["bearer"]
.as_str()
.expect("raw bearer in response")
.to_string();
assert!(body["token_hash"].is_null(), "token_hash must not leak");
// Use the bearer to hit /me — should authenticate.
let resp = h
.app
.oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[sqlx::test(migrations = "./migrations")]
async fn user_a_cannot_delete_user_b_token(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 resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/auth/tokens",
json!({ "name": "alice-bot" }),
&cookie_a,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = common::body_json(resp).await;
let token_id = body["id"].as_str().unwrap().to_string();
// User B attempts to delete user A's token → 403.
let resp = h
.app
.clone()
.oneshot(common::delete_with_cookie(
&format!("/api/v1/auth/tokens/{token_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/auth/tokens/{token_id}"),
&cookie_a,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_unknown_token_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/auth/tokens/00000000-0000-0000-0000-000000000000",
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}