leistungsbilanz-ts/tests/authentication.test.ts
Julian Appel 7222398628 Typecheck tests against current types
tsx runs tests without type checking and tsconfig.json covers only src,
so 71 type errors had accumulated unnoticed. tsconfig.tests.json now
checks every test and helper, and npm run typecheck includes it.

Fixtures gain the fields the DTOs have grown since they were written
(manualQuantity, circuit apparent power/cos phi, downstream board loads,
section totals, building, cover and board info fields). Repository
lookups that return "row ?? null" now declare the null in their type,
which their callers already handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:17:45 +01:00

321 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 headers: Record<string, string> = {
...(fetchSite ? { "sec-fetch-site": fetchSite } : {}),
...(includeApplicationHeader
? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE }
: {}),
};
const req = {
method,
headers,
get(name: string) {
return headers[name.toLowerCase()];
},
} 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);
});
});