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>
This commit is contained in:
MechaCat02
2026-05-16 23:27:19 +02:00
parent 80ab119750
commit 785b9755cf
8 changed files with 100 additions and 4 deletions

View File

@@ -64,6 +64,33 @@ async fn register_rejects_duplicate_username_with_conflict(pool: PgPool) {
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);