mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
* 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
123 lines
3.6 KiB
TypeScript
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);
|
|
});
|
|
});
|