test(01-03): add atomic reset revocation contracts

- execute the operator CLI against real PostgreSQL credential tables
- require complete target revocation and unrelated-admin isolation
- force a mid-transaction database failure to prove rollback
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 13:42:04 +02:00
1 parent eb5cd5fbcb
commit abea48026b
2 files changed
+341 -3

No files matched your search

+48 -3
View File
@@ -12,7 +12,8 @@
* - 404 when the target user does not exist
* - 422 when the new password is weak
* - happy path wipes the target user's sessions, API tokens, refresh
* tokens and writes an `admin.user.reset-password` audit row
* tokens, trusted devices and step-up elevations while preserving the
* unrelated admin credential family
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -179,8 +180,8 @@ describe("POST /api/admin/users/[id]/reset-password (real Postgres)", () => {
expect(after!.passwordHash).toBe(before!.passwordHash);
});
it("wipes the target user's sessions, API tokens and refresh tokens and writes an audit row", async () => {
const { callerId } = await seedCaller("ADMIN");
it("revokes the complete target credential family while preserving the admin", async () => {
const { callerId, sessionId: adminSessionId } = await seedCaller("ADMIN");
const targetId = await seedTargetUser();
const prisma = getPrismaClient();
@@ -206,6 +207,30 @@ describe("POST /api/admin/users/[id]/reset-password (real Postgres)", () => {
revokedAt: null,
},
});
const targetTrustedDevice = await prisma.trustedDevice.create({
data: {
userId: targetId,
tokenHash: "hashed-target-trusted-device",
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
const targetElevation = await prisma.stepUpElevation.create({
data: {
userId: targetId,
apiTokenId: targetApiToken.id,
tokenHash: "hashed-target-elevation",
method: "passkey",
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
},
});
const adminApiToken = await prisma.apiToken.create({
data: {
userId: callerId,
name: "Admin device",
tokenHash: "hashed-admin-token",
revoked: false,
},
});
const { POST } =
await import("@/app/api/admin/users/[id]/reset-password/route");
@@ -234,6 +259,26 @@ describe("POST /api/admin/users/[id]/reset-password (real Postgres)", () => {
});
expect(refreshedRefresh?.revokedAt).not.toBeNull();
// Trusted-device and fresh-factor proofs are credentials too.
expect(
await prisma.trustedDevice.findUnique({
where: { id: targetTrustedDevice.id },
}),
).toBeNull();
expect(
await prisma.stepUpElevation.findUnique({
where: { id: targetElevation.id },
}),
).toBeNull();
// The administrator's unrelated credentials remain usable.
expect(
await prisma.session.findUnique({ where: { id: adminSessionId } }),
).not.toBeNull();
expect(
await prisma.apiToken.findUnique({ where: { id: adminApiToken.id } }),
).toMatchObject({ revoked: false });
// Audit row exists and is attributed to the admin caller.
const audit = await prisma.auditLog.findFirst({
where: { action: "admin.user.reset-password" },
@@ -0,0 +1,293 @@
/**
* SEC-07 / SEC-08 — 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()
`);
}
});
});