Files
HealthLog/tests/integration/auth-ledger-cleanup.test.ts
T
Marc-André Bombeck 566fd8c6b3 feat(jobs): clean expired sessions, tokens and challenges nightly
Sessions, refresh tokens, access tokens, trusted devices and the
second-factor and passkey challenges were never removed after they
expired, so the sign-in tables were mostly dead rows.

auth-ledger-cleanup deletes a session, token or trusted device 30 days
after it expired, which keeps every row the login throttle's 30-day
known-address check and refresh reuse detection can still read; a revoked
token without an expiry stays listed. Challenges go a day after expiry.
Security activity reads the audit log and loses nothing.
2026-10-20 13:17:41 +02:00

186 lines
5.6 KiB
TypeScript

/**
* The nightly cleanup of the sign-in ledgers (`auth-ledger-cleanup`).
*
* A row goes once it has been expired for longer than its grace (30 days for
* sessions, tokens and trusted devices, a day for challenges). A live row, a
* recently expired one, a revoked token that has not expired, and a revoked
* token without an expiry all stay. The delete is batched and says when it
* stopped with rows left.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cleanupAuthLedgers } from "@/lib/jobs/auth-ledger-cleanup";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const USER = "auth-ledger-user";
const NOW = new Date("2026-10-20T03:35:00.000Z");
const DAY = 86_400_000;
const LONG_EXPIRED = new Date(NOW.getTime() - 40 * DAY);
const JUST_EXPIRED = new Date(NOW.getTime() - 10 * DAY);
const LIVE = new Date(NOW.getTime() + 10 * DAY);
const prisma = getPrismaClient();
beforeEach(async () => {
await truncateAllTables(prisma);
await prisma.user.create({
data: { id: USER, username: USER, role: "USER", timezone: "UTC" },
});
});
async function ids(rows: Promise<Array<{ id: string }>>): Promise<string[]> {
return (await rows).map((r) => r.id).sort();
}
describe("auth ledger cleanup", () => {
it("removes rows expired past their grace and keeps everything else", async () => {
for (const [id, expiresAt] of [
["session-old", LONG_EXPIRED],
["session-recent", JUST_EXPIRED],
["session-live", LIVE],
] as const) {
await prisma.session.create({ data: { id, userId: USER, expiresAt } });
}
for (const [id, expiresAt, revokedAt, usedAt] of [
["refresh-old", LONG_EXPIRED, null, LONG_EXPIRED],
["refresh-recent", JUST_EXPIRED, null, null],
// Revoked but not yet expired: reuse detection may still need it.
["refresh-revoked-live", LIVE, NOW, null],
["refresh-used-live", LIVE, null, NOW],
] as const) {
await prisma.refreshToken.create({
data: {
id,
userId: USER,
tokenHash: `hash-${id}`,
deviceId: "device-1",
expiresAt,
revokedAt,
usedAt,
},
});
}
for (const [id, expiresAt, revoked] of [
["token-old", LONG_EXPIRED, false],
["token-old-revoked", LONG_EXPIRED, true],
["token-recent", JUST_EXPIRED, false],
["token-live", LIVE, false],
// No expiry, revoked: nothing says when, so it stays listed.
["token-revoked-forever", null, true],
["token-forever", null, false],
] as const) {
await prisma.apiToken.create({
data: {
id,
userId: USER,
name: id,
tokenHash: `hash-${id}`,
expiresAt,
revoked,
},
});
}
for (const [id, expiresAt] of [
["trusted-old", LONG_EXPIRED],
["trusted-recent", JUST_EXPIRED],
] as const) {
await prisma.trustedDevice.create({
data: { id, userId: USER, tokenHash: `hash-${id}`, expiresAt },
});
}
for (const [id, expiresAt] of [
["mfa-old", new Date(NOW.getTime() - 2 * DAY)],
["mfa-recent", new Date(NOW.getTime() - 60 * 60_000)],
] as const) {
await prisma.mfaChallenge.create({
data: {
id,
userId: USER,
kind: "totp",
ticketHash: `hash-${id}`,
expiresAt,
},
});
}
for (const [id, expiresAt] of [
["challenge-old", new Date(NOW.getTime() - 2 * DAY)],
["challenge-recent", new Date(NOW.getTime() - 60 * 60_000)],
] as const) {
await prisma.authChallenge.create({
data: {
id,
userId: null,
challenge: id,
type: "registration",
expiresAt,
},
});
}
const result = await cleanupAuthLedgers(prisma, NOW);
expect(result).toEqual({
deleted: {
sessions: 1,
refresh_tokens: 1,
api_tokens: 2,
trusted_devices: 1,
mfa_challenges: 1,
auth_challenges: 1,
},
drained: true,
});
expect(
await ids(prisma.session.findMany({ select: { id: true } })),
).toEqual(["session-live", "session-recent"]);
expect(
await ids(prisma.refreshToken.findMany({ select: { id: true } })),
).toEqual(["refresh-recent", "refresh-revoked-live", "refresh-used-live"]);
expect(
await ids(prisma.apiToken.findMany({ select: { id: true } })),
).toEqual([
"token-forever",
"token-live",
"token-recent",
"token-revoked-forever",
]);
expect(
await ids(prisma.trustedDevice.findMany({ select: { id: true } })),
).toEqual(["trusted-recent"]);
expect(
await ids(prisma.mfaChallenge.findMany({ select: { id: true } })),
).toEqual(["mfa-recent"]);
expect(
await ids(prisma.authChallenge.findMany({ select: { id: true } })),
).toEqual(["challenge-recent"]);
});
it("deletes in batches and reports a table it could not finish", async () => {
for (let i = 0; i < 5; i++) {
await prisma.session.create({
data: { id: `old-${i}`, userId: USER, expiresAt: LONG_EXPIRED },
});
}
const capped = await cleanupAuthLedgers(prisma, NOW, {
batchSize: 2,
maxBatches: 2,
});
expect(capped.deleted.sessions).toBe(4);
expect(capped.drained).toBe(false);
const rest = await cleanupAuthLedgers(prisma, NOW, {
batchSize: 2,
maxBatches: 2,
});
expect(rest.deleted.sessions).toBe(1);
expect(rest.drained).toBe(true);
expect(await prisma.session.count()).toBe(0);
});
});