import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() })); vi.mock("../../../utils/logger.js", () => ({ databaseLogger: logs, })); const { TestSqliteDatabase } = await import("./test-support.js"); const { AuditLogRepository, auditRetentionDays, auditMaxEntries, AUDIT_RETENTION_DAYS_ENV, AUDIT_MAX_ENTRIES_ENV, } = await import("../../../database/repositories/audit-log-repository.js"); let adapter: InstanceType | null = null; const savedEnv: Record = {}; beforeEach(() => { logs.info.mockReset(); logs.warn.mockReset(); for (const key of [AUDIT_RETENTION_DAYS_ENV, AUDIT_MAX_ENTRIES_ENV]) { savedEnv[key] = process.env[key]; delete process.env[key]; } }); afterEach(async () => { for (const [key, value] of Object.entries(savedEnv)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } if (adapter) { await adapter.close(); adapter = null; } }); async function createRepository() { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('u-1', 'u-1', 'hash'); `); return new AuditLogRepository(context); } function daysAgo(days: number): string { const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000); return d.toISOString().slice(0, 19).replace("T", " "); } async function seed( repo: Awaited>, timestamp: string, action = "create_host", ) { await repo.create({ userId: "u-1", username: "alice", action, resourceType: "host", success: true, timestamp, }); } describe("audit retention configuration", () => { it("has no time limit unless one is configured", () => { expect(auditRetentionDays({})).toBeNull(); expect(auditRetentionDays({ [AUDIT_RETENTION_DAYS_ENV]: "90" })).toBe(90); }); it("ignores values that are not a positive count", () => { for (const bad of ["0", "-5", "", "abc"]) { expect( auditRetentionDays({ [AUDIT_RETENTION_DAYS_ENV]: bad }), ).toBeNull(); } }); it("falls back to the built-in cap", () => { expect(auditMaxEntries({})).toBe(10000); expect(auditMaxEntries({ [AUDIT_MAX_ENTRIES_ENV]: "250" })).toBe(250); expect(auditMaxEntries({ [AUDIT_MAX_ENTRIES_ENV]: "-1" })).toBe(10000); }); }); describe("audit retention pruning", () => { it("keeps everything when no retention is set", async () => { const repo = await createRepository(); await seed(repo, daysAgo(400)); await seed(repo, daysAgo(1)); const { total } = await repo.listPage({ filters: {}, limit: 10, offset: 0, }); expect(total).toBe(2); expect(logs.info).not.toHaveBeenCalled(); }); it("drops entries past the retention window and says so", async () => { process.env[AUDIT_RETENTION_DAYS_ENV] = "30"; const repo = await createRepository(); await seed(repo, daysAgo(90), "old_action"); await seed(repo, daysAgo(5), "recent_action"); const { logs: rows } = await repo.listPage({ filters: {}, limit: 10, offset: 0, }); expect(rows.map((r) => r.action)).toEqual(["recent_action"]); expect(logs.info).toHaveBeenCalledWith( expect.stringContaining("past retention"), expect.objectContaining({ operation: "audit_retention_prune" }), ); }); it("warns when the row cap discards entries still inside the window", async () => { process.env[AUDIT_MAX_ENTRIES_ENV] = "5"; const repo = await createRepository(); for (let i = 0; i < 6; i++) { await seed(repo, daysAgo(10 - i), `action_${i}`); } // The cap is not a retention policy: these entries were still current. expect(logs.warn).toHaveBeenCalledWith( expect.stringContaining("cap"), expect.objectContaining({ operation: "audit_overflow_prune", maxEntries: 5, }), ); const { total } = await repo.listPage({ filters: {}, limit: 20, offset: 0, }); expect(total).toBeLessThan(6); }); it("stays quiet while under the cap", async () => { process.env[AUDIT_MAX_ENTRIES_ENV] = "100"; const repo = await createRepository(); await seed(repo, daysAgo(1)); expect(logs.warn).not.toHaveBeenCalled(); }); });