Files
Termix/src/backend/tests/database/repositories/user-repository.test.ts
T
ZacharyZcR e610aba0e5 fix: enforce unique usernames (#1628)
* fix: enforce unique usernames

Registration checked for a taken name, hashed the password, then
inserted, with nothing in the schema enforcing uniqueness. Concurrent
sign-ups could create two accounts with one name, after which login,
password reset, proxy login and admin lookups by username picked
between them arbitrarily.

Add a unique index on users.username for every dialect. Existing
duplicates are renamed (later rows get a -<id> suffix, the oldest keeps
the name) rather than deleted. Registration and admin user creation now
return the same 409 when the insert loses the race.

* fix: avoid username collisions during migration
2026-10-14 08:17:47 +08:00

123 lines
3.6 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import {
UserRepository,
isUsernameTaken,
} from "../../../database/repositories/user-repository.js";
describe("UserRepository.listPage", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(): Promise<UserRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('u1', 'alice', 'hash'),
('u2', 'Bob', 'hash'),
('u3', 'carol', 'hash'),
('u4', 'dave', 'hash'),
('u5', 'alicia', 'hash');
`);
return new UserRepository(context);
}
it("returns a page ordered by username with the full total", async () => {
const repo = await createRepository();
const page = await repo.listPage({ limit: 2, offset: 0 });
expect(page.users.map((u) => u.username)).toEqual(["alice", "alicia"]);
// total counts every match, not just the returned page.
expect(page.total).toBe(5);
});
it("pages forward without repeating or skipping a row", async () => {
const repo = await createRepository();
const first = await repo.listPage({ limit: 2, offset: 0 });
const second = await repo.listPage({ limit: 2, offset: 2 });
const third = await repo.listPage({ limit: 2, offset: 4 });
expect(
[...first.users, ...second.users, ...third.users].map((u) => u.username),
).toEqual(["alice", "alicia", "Bob", "carol", "dave"]);
});
it("filters by username case-insensitively", async () => {
const repo = await createRepository();
const page = await repo.listPage({ search: "ALI", limit: 10, offset: 0 });
expect(page.users.map((u) => u.username)).toEqual(["alice", "alicia"]);
expect(page.total).toBe(2);
});
it("counts only matching rows when searching", async () => {
const repo = await createRepository();
const page = await repo.listPage({ search: "ali", limit: 1, offset: 0 });
expect(page.users).toHaveLength(1);
expect(page.total).toBe(2);
});
it("returns an empty page for a term nobody matches", async () => {
const repo = await createRepository();
const page = await repo.listPage({ search: "zzz", limit: 10, offset: 0 });
expect(page.users).toEqual([]);
expect(page.total).toBe(0);
});
it("treats a blank search as no filter", async () => {
const repo = await createRepository();
const page = await repo.listPage({ search: " ", limit: 10, offset: 0 });
expect(page.total).toBe(5);
});
it("returns an empty page past the end of the results", async () => {
const repo = await createRepository();
const page = await repo.listPage({ limit: 10, offset: 99 });
expect(page.users).toEqual([]);
expect(page.total).toBe(5);
});
});
describe("UserRepository.create", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
it("rejects a taken username in a way callers can recognise", async () => {
adapter = new TestSqliteDatabase();
const repo = new UserRepository(await adapter.connect());
await repo.create({ id: "u1", username: "alice", passwordHash: "hash" });
const error = await repo
.create({ id: "u2", username: "alice", passwordHash: "hash" })
.catch((e: unknown) => e);
expect(isUsernameTaken(error)).toBe(true);
expect(isUsernameTaken(new Error("unrelated"))).toBe(false);
});
});