import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import type { Request, Response } from "express"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext } from "../src/db/database-context.js"; import { UserRepository } from "../src/db/repositories/user.repository.js"; import { InvalidCredentialsError, LastAdminError, PasswordUnchangedError, UserService, UsernameTakenError, WrongPasswordError, } from "../src/domain/services/user.service.js"; import { hashPassword, verifyPassword } from "../src/server/auth/password.js"; import { SessionStore } from "../src/server/auth/sessions.js"; import { LoginThrottle } from "../src/server/auth/login-throttle.js"; import { readCookie } from "../src/server/auth/cookie.js"; import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.js"; import type { AuthConfig } from "../src/server/auth/auth-config.js"; import { createUserSchema } from "../src/shared/validation/user.schemas.js"; import { loginSchema } from "../src/shared/validation/auth.schemas.js"; import { WRITE_REQUEST_HEADER_NAME, WRITE_REQUEST_HEADER_VALUE, } from "../src/shared/constants/request-security.js"; const HOUR = 60 * 60 * 1000; function createUserService() { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); const repository = new UserRepository(context.db); const config: AuthConfig = { sessionTtlMs: 12 * HOUR, sessionMaxAgeMs: 7 * 24 * HOUR, cookieSecure: false, bootstrap: { username: "admin", password: "bootstrap-password-123" }, }; return { repository, service: new UserService( repository, { bootstrap: config.bootstrap }, { hash: hashPassword, verify: verifyPassword } ), }; } describe("password hashing", () => { it("verifies the right password and rejects a wrong one", async () => { const hash = await hashPassword("richtiges-passwort"); assert.match(hash, /^scrypt\$16384\$8\$1\$[0-9a-f]{32}\$[0-9a-f]{128}$/); assert.equal(await verifyPassword("richtiges-passwort", hash), true); assert.equal(await verifyPassword("falsches-passwort", hash), false); }); it("uses a fresh salt per hash", async () => { assert.notEqual(await hashPassword("gleich"), await hashPassword("gleich")); }); it("rejects malformed stored hashes instead of throwing", async () => { assert.equal(await verifyPassword("x", "not-a-hash"), false); assert.equal(await verifyPassword("x", "scrypt$a$b$c$00$00"), false); assert.equal( await verifyPassword( "x", `scrypt$1073741824$8$1$${"00".repeat(16)}$${"00".repeat(64)}` ), false ); assert.equal( await verifyPassword("x", `scrypt$16384$8$1$00$${"00".repeat(64)}`), false ); }); }); describe("session store", () => { it("expires idle sessions and slides on activity", () => { let now = 0; const store = new SessionStore(1 * HOUR, 24 * HOUR, () => now); const session = store.create("user-1"); now = 50 * 60 * 1000; assert.ok(store.get(session.id)); now += 50 * 60 * 1000; assert.ok(store.get(session.id), "activity must have extended the idle window"); now += 2 * HOUR; assert.equal(store.get(session.id), undefined); }); it("never outlives the absolute lifetime, even when constantly used", () => { let now = 0; const store = new SessionStore(1 * HOUR, 3 * HOUR, () => now); const session = store.create("user-1"); for (let step = 0; step < 5; step += 1) { now += 30 * 60 * 1000; store.get(session.id); } now = 3 * HOUR; assert.equal(store.get(session.id), undefined); }); it("ends all sessions of a user except an optional one", () => { const store = new SessionStore(HOUR, 24 * HOUR); const first = store.create("user-1"); const second = store.create("user-1"); const other = store.create("user-2"); store.destroyAllForUser("user-1", second.id); assert.equal(store.get(first.id), undefined); assert.ok(store.get(second.id)); assert.ok(store.get(other.id)); }); it("bounds the number of sessions per user by dropping the oldest", () => { let now = 0; const store = new SessionStore(HOUR, 24 * HOUR, () => now); const oldest = store.create("user-1"); for (let index = 0; index < 20; index += 1) { now += 1; store.create("user-1"); } assert.equal(store.get(oldest.id), undefined); }); }); describe("login throttle", () => { it("blocks a username after repeated failures and releases it after the window", () => { let now = 0; const throttle = new LoginThrottle(3, 15 * 60 * 1000, 100, () => now); for (let attempt = 0; attempt < 3; attempt += 1) { assert.equal(throttle.retryAfterMs("Admin"), 0); throttle.recordFailure("admin "); } assert.ok(throttle.retryAfterMs("ADMIN") > 0, "normalized usernames share a counter"); assert.equal(throttle.retryAfterMs("someone-else"), 0); now = 15 * 60 * 1000 + 1; assert.equal(throttle.retryAfterMs("admin"), 0); }); it("resets the counter after a successful login", () => { const throttle = new LoginThrottle(2); throttle.recordFailure("admin"); throttle.recordSuccess("admin"); throttle.recordFailure("admin"); assert.equal(throttle.retryAfterMs("admin"), 0); }); }); describe("cookie parsing", () => { it("reads the session cookie and ignores malformed encodings", () => { assert.equal(readCookie("a=1; lb_session=abc; b=2", "lb_session"), "abc"); assert.equal(readCookie("lb_session=%E0%A4%A", "lb_session"), undefined); assert.equal(readCookie(undefined, "lb_session"), undefined); }); }); describe("same-origin guard for writes", () => { function run(method: string, fetchSite?: string, includeApplicationHeader = true) { let status: number | undefined; let passed = false; const req = { method, headers: { ...(fetchSite ? { "sec-fetch-site": fetchSite } : {}), ...(includeApplicationHeader ? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE } : {}), }, get(name: string) { return this.headers[name.toLowerCase() as keyof typeof this.headers]; }, } as unknown as Request; const res = { status(code: number) { status = code; return this; }, json() { return this; }, } as unknown as Response; requireSameOriginForWrites()(req, res, () => { passed = true; }); return { passed, status }; } it("lets reads and same-origin writes through", () => { assert.equal(run("GET", "cross-site", false).passed, true); assert.equal(run("POST", "same-origin").passed, true); assert.equal(run("POST").passed, true, "scripts can send the explicit application header"); }); it("rejects writes without the application header or from another origin", () => { assert.deepEqual(run("POST", undefined, false), { passed: false, status: 403 }); assert.deepEqual(run("POST", "cross-site"), { passed: false, status: 403 }); assert.deepEqual(run("PATCH", "same-site"), { passed: false, status: 403 }); assert.deepEqual(run("DELETE", "same-site"), { passed: false, status: 403 }); }); }); describe("input validation", () => { it("rejects look-alike and whitespace usernames and normalizes case", () => { const base = { name: "Test", role: "user", initialPassword: "start-passwort" }; assert.equal(createUserSchema.safeParse({ ...base, username: "admіn" }).success, false); assert.equal(createUserSchema.safeParse({ ...base, username: "ad min" }).success, false); const parsed = createUserSchema.parse({ ...base, username: " Max.Muster@Firma.de " }); assert.equal(parsed.username, "max.muster@firma.de"); }); it("caps login input length so huge bodies never reach scrypt", () => { assert.equal( loginSchema.safeParse({ username: "admin", password: "x".repeat(201) }).success, false ); }); }); describe("user service", () => { it("creates the bootstrap admin only while the table is empty", async () => { const { repository, service } = createUserService(); await service.ensureBootstrapAdmin(); await service.ensureBootstrapAdmin(); const users = await repository.list(); assert.equal(users.length, 1); assert.equal(users[0].role, "admin"); assert.equal(users[0].mustChangePassword, true); }); it("rejects unknown users, wrong passwords and deactivated accounts alike", async () => { const { service } = createUserService(); const user = await service.createUser({ username: "max", name: "Max", role: "user", initialPassword: "start-passwort", }); assert.ok(user); await assert.rejects(service.authenticate("niemand", "egal"), InvalidCredentialsError); await assert.rejects(service.authenticate("max", "falsch"), InvalidCredentialsError); assert.equal((await service.authenticate("MAX", "start-passwort")).id, user.id); await service.updateRoleAndActive(user.id, { active: false }); await assert.rejects(service.authenticate("max", "start-passwort"), InvalidCredentialsError); }); it("refuses duplicate usernames", async () => { const { service } = createUserService(); const input = { username: "max", name: "Max", role: "user" as const, initialPassword: "start-passwort" }; await service.createUser(input); await assert.rejects(service.createUser(input), UsernameTakenError); }); it("never removes the last active admin", async () => { const { repository, service } = createUserService(); await service.ensureBootstrapAdmin(); const [admin] = await repository.list(); await assert.rejects(service.updateRoleAndActive(admin.id, { role: "user" }), LastAdminError); await assert.rejects(service.updateRoleAndActive(admin.id, { active: false }), LastAdminError); const second = await service.createUser({ username: "zweiter", name: "Zweiter", role: "admin", initialPassword: "start-passwort", }); assert.ok(second); await service.updateRoleAndActive(admin.id, { role: "user" }); await assert.rejects(service.updateRoleAndActive(second.id, { active: false }), LastAdminError); }); it("preserves one active admin across concurrent updates", async () => { const { repository, service } = createUserService(); await service.ensureBootstrapAdmin(); const second = await service.createUser({ username: "zweiter-admin", name: "Zweiter Admin", role: "admin", initialPassword: "start-passwort", }); assert.ok(second); const [first] = (await repository.list()).filter((user) => user.username === "admin"); const results = await Promise.allSettled([ service.updateRoleAndActive(first.id, { role: "user" }), service.updateRoleAndActive(second.id, { role: "user" }), ]); assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); assert.equal(results.filter((result) => result.status === "rejected").length, 1); const activeAdmins = (await repository.list()).filter( (user) => user.role === "admin" && user.active ); assert.equal(activeAdmins.length, 1); }); it("requires the current password and a different new one", async () => { const { service } = createUserService(); const user = await service.createUser({ username: "max", name: "Max", role: "user", initialPassword: "start-passwort", }); assert.ok(user); await assert.rejects( service.changeOwnPassword(user.id, "falsch", "neues-passwort"), WrongPasswordError ); await assert.rejects( service.changeOwnPassword(user.id, "start-passwort", "start-passwort"), PasswordUnchangedError ); const changed = await service.changeOwnPassword(user.id, "start-passwort", "neues-passwort"); assert.equal(changed?.mustChangePassword, false); const reset = await service.resetPassword(user.id, "admin-vergeben"); assert.equal(reset?.mustChangePassword, true); }); });