Files
HealthLog/tests/integration/reset-password-revocation.test.ts
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

294 lines
8.8 KiB
TypeScript

/**
* Operator password-reset recovery contract.
*
* The production `.mjs` CLI is executed as a real child process against the
* PostgreSQL Testcontainer. Success must be one credential-family event:
* password rotation plus revocation of browser, native, API, trusted-device,
* and elevation credentials. The rollback case installs a test-only trigger
* that raises during session deletion, proving the whole transaction unwinds
* without a production schema change or a test-only branch in the CLI.
*/
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const { hashPassword, verifyPassword } = await import("@/lib/auth/password");
const TARGET_ID = "reset-cli-target";
const ADMIN_ID = "reset-cli-admin";
const OLD_PASSWORD = "Old target password!123";
const ADMIN_PASSWORD = "Unrelated admin password!456";
const NEW_PASSWORD = "New target password!789";
type CliResult = {
code: number | null;
stdout: string;
stderr: string;
};
function runReset(identifier: string, password: string): Promise<CliResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(
process.execPath,
[
resolve(process.cwd(), "scripts/reset-password.mjs"),
identifier,
password,
],
{
cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: process.env.DATABASE_URL },
stdio: ["ignore", "pipe", "pipe"],
},
);
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", reject);
child.once("close", (code) => resolveResult({ code, stdout, stderr }));
});
}
async function seedCredentialFamily(
userId: string,
prefix: string,
): Promise<void> {
const prisma = getPrismaClient();
await prisma.session.create({
data: {
id: `${prefix}-session`,
userId,
expiresAt: new Date(Date.now() + 60 * 60_000),
},
});
const apiToken = await prisma.apiToken.create({
data: {
id: `${prefix}-api`,
userId,
name: `${prefix} API`,
tokenHash: `${prefix}-api-hash`,
permissions: ["*"],
revoked: false,
},
});
await prisma.refreshToken.create({
data: {
id: `${prefix}-refresh`,
userId,
tokenHash: `${prefix}-refresh-hash`,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
revokedAt: null,
},
});
await prisma.trustedDevice.create({
data: {
id: `${prefix}-trusted`,
userId,
tokenHash: `${prefix}-trusted-hash`,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
},
});
await prisma.stepUpElevation.create({
data: {
id: `${prefix}-elevation`,
userId,
apiTokenId: apiToken.id,
tokenHash: `${prefix}-elevation-hash`,
method: "passkey",
expiresAt: new Date(Date.now() + 5 * 60_000),
},
});
}
async function snapshot(userId: string) {
const prisma = getPrismaClient();
const [user, sessions, apiTokens, refreshTokens, trustedDevices, elevations] =
await Promise.all([
prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { passwordHash: true },
}),
prisma.session.findMany({
where: { userId },
orderBy: { id: "asc" },
select: { id: true },
}),
prisma.apiToken.findMany({
where: { userId },
orderBy: { id: "asc" },
select: { id: true, revoked: true },
}),
prisma.refreshToken.findMany({
where: { userId },
orderBy: { id: "asc" },
select: { id: true, revokedAt: true },
}),
prisma.trustedDevice.findMany({
where: { userId },
orderBy: { id: "asc" },
select: { id: true },
}),
prisma.stepUpElevation.findMany({
where: { userId },
orderBy: { id: "asc" },
select: { id: true },
}),
]);
return {
passwordHash: user.passwordHash,
sessions,
apiTokens,
refreshTokens: refreshTokens.map((row) => ({
id: row.id,
revokedAt: row.revokedAt?.toISOString() ?? null,
})),
trustedDevices,
elevations,
};
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
const prisma = getPrismaClient();
await prisma.user.createMany({
data: [
{
id: TARGET_ID,
username: "reset-target",
email: "reset-target@example.test",
role: "USER",
passwordHash: await hashPassword(OLD_PASSWORD),
},
{
id: ADMIN_ID,
username: "reset-admin",
email: "reset-admin@example.test",
role: "ADMIN",
passwordHash: await hashPassword(ADMIN_PASSWORD),
},
],
});
await seedCredentialFamily(TARGET_ID, "target");
await seedCredentialFamily(ADMIN_ID, "admin");
});
describe("scripts/reset-password.mjs (real Postgres)", () => {
it("changes the target password and revokes every target credential family only", async () => {
const adminBefore = await snapshot(ADMIN_ID);
const result = await runReset("reset-target@example.test", NEW_PASSWORD);
expect(result.code).toBe(0);
expect(result.stdout).toContain('password updated for "reset-target"');
expect(result.stdout).not.toContain(NEW_PASSWORD);
expect(result.stderr).not.toContain(NEW_PASSWORD);
const targetAfter = await snapshot(TARGET_ID);
expect(targetAfter.passwordHash).toBeTruthy();
expect(await verifyPassword(targetAfter.passwordHash!, NEW_PASSWORD)).toBe(
true,
);
expect(await verifyPassword(targetAfter.passwordHash!, OLD_PASSWORD)).toBe(
false,
);
expect(targetAfter.sessions).toEqual([]);
expect(targetAfter.apiTokens).toEqual([
{ id: "target-api", revoked: true },
]);
expect(targetAfter.refreshTokens).toHaveLength(1);
expect(targetAfter.refreshTokens[0].revokedAt).not.toBeNull();
expect(targetAfter.trustedDevices).toEqual([]);
expect(targetAfter.elevations).toEqual([]);
expect(await snapshot(ADMIN_ID)).toEqual(adminBefore);
expect(
await verifyPassword(adminBefore.passwordHash!, ADMIN_PASSWORD),
).toBe(true);
});
it("leaves all state unchanged for unknown, ambiguous, and weak input", async () => {
const prisma = getPrismaClient();
await prisma.user.createMany({
data: [
{
id: "ambiguous-username",
username: "ambiguous",
email: "first-ambiguous@example.test",
passwordHash: await hashPassword(OLD_PASSWORD),
},
{
id: "ambiguous-email",
username: "second-ambiguous",
email: "ambiguous",
passwordHash: await hashPassword(OLD_PASSWORD),
},
],
});
const before = await snapshot(TARGET_ID);
const unknown = await runReset("nobody@example.test", NEW_PASSWORD);
const ambiguous = await runReset("ambiguous", NEW_PASSWORD);
const weak = await runReset("reset-target", "too-short");
expect(unknown.code).not.toBe(0);
expect(unknown.stderr).toContain("no user matches");
expect(ambiguous.code).not.toBe(0);
expect(ambiguous.stderr).toContain("refusing to guess");
expect(weak.code).not.toBe(0);
expect(weak.stderr).toContain("at least 12 characters");
expect(await snapshot(TARGET_ID)).toEqual(before);
});
it("rolls back the password and every credential mutation after a real mid-transaction failure", async () => {
const prisma = getPrismaClient();
const before = await snapshot(TARGET_ID);
await prisma.$executeRawUnsafe(`
CREATE OR REPLACE FUNCTION test_reset_password_fail_session_delete()
RETURNS trigger AS $$
BEGIN
IF OLD.user_id = '${TARGET_ID}' THEN
RAISE EXCEPTION 'forced reset rollback';
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql
`);
await prisma.$executeRawUnsafe(`
CREATE TRIGGER test_reset_password_fail_session_delete
BEFORE DELETE ON sessions
FOR EACH ROW EXECUTE FUNCTION test_reset_password_fail_session_delete()
`);
try {
const result = await runReset("reset-target", NEW_PASSWORD);
expect(result.code).not.toBe(0);
expect(result.stderr).toContain("forced reset rollback");
expect(await snapshot(TARGET_ID)).toEqual(before);
expect(await verifyPassword(before.passwordHash!, OLD_PASSWORD)).toBe(
true,
);
} finally {
await prisma.$executeRawUnsafe(`
DROP TRIGGER IF EXISTS test_reset_password_fail_session_delete
ON sessions
`);
await prisma.$executeRawUnsafe(`
DROP FUNCTION IF EXISTS test_reset_password_fail_session_delete()
`);
}
});
});