Files
Termix/src/backend/tests/database/repositories/audit-log-repository.test.ts
T
Luke Gustafson 2b309e9582 release-2.9.2 (#1566)
* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows
2026-10-05 20:24:46 -05:00

341 lines
9.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import { TestSqliteDatabase } from "./test-support.js";
import { AuditLogRepository } from "../../../database/repositories/audit-log-repository.js";
describe("AuditLogRepository", () => {
let adapter: TestSqliteDatabase | null = null;
beforeEach(() => {
AuditLogRepository.resetPruneThrottleForTests();
});
afterEach(async () => {
delete process.env.AUDIT_LOG_MAX_ENTRIES;
delete process.env.AUDIT_LOG_RETENTION_DAYS;
AuditLogRepository.resetPruneThrottleForTests();
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<AuditLogRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new AuditLogRepository(context, onWrite);
}
it("creates, filters, pages, and lists actions", async () => {
const repo = await createRepository();
await repo.create({
userId: "user-1",
username: "alice",
action: "create_host",
resourceType: "host",
resourceId: "1",
success: true,
timestamp: "2026-06-27T00:00:00.000Z",
});
await repo.create({
userId: "user-2",
username: "bob",
action: "delete_host",
resourceType: "host",
resourceId: "2",
success: false,
timestamp: "2026-06-27T01:00:00.000Z",
});
const page = await repo.listPage({
filters: {
resourceType: "host",
success: false,
startDate: "2026-06-27T00:30:00.000Z",
},
limit: 10,
offset: 0,
});
expect(page.total).toBe(1);
expect(page.logs[0]).toMatchObject({
userId: "user-2",
action: "delete_host",
success: false,
});
expect(await repo.listDistinctActions()).toEqual([
"create_host",
"delete_host",
]);
});
it("stores entries with no acting user, and refuses ids that are not users", async () => {
const repo = await createRepository();
await repo.create({
userId: null,
username: "plugin:example",
action: "plugin_http_public_routes",
resourceType: "plugin",
success: true,
});
await expect(
repo.create({
userId: "system",
username: "system",
action: "cleanup",
resourceType: "plugin",
success: true,
}),
).rejects.toThrow();
const page = await repo.listPage({ filters: {}, limit: 10, offset: 0 });
expect(page.logs).toHaveLength(1);
expect(page.logs[0]).toMatchObject({
userId: null,
username: "plugin:example",
});
});
it("deletes logs by user id and only runs write hook for deleted rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
userId: "user-1",
username: "alice",
action: "login",
resourceType: "auth",
success: true,
});
await repo.create({
userId: "user-2",
username: "bob",
action: "login",
resourceType: "auth",
success: true,
});
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(3);
expect(
(
await repo.listPage({
filters: {},
limit: 10,
offset: 0,
})
).logs.map((log) => log.userId),
).toEqual(["user-2"]);
});
it("clears only background routine plugin entries", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const entry = (userId: string | null, action: string) =>
repo.create({
userId,
username: "plugin:host-metrics",
action,
resourceType: "plugin",
success: true,
});
await entry(null, "plugin_db_client");
await entry(null, "plugin_kv_get");
await entry(null, "plugin_kv_set");
await entry("user-1", "plugin_db_client");
await entry(null, "plugin_as_user");
writeCount = 0;
expect(await repo.deleteRoutinePluginNoise()).toBe(2);
expect(writeCount).toBe(1);
const left = (
await repo.listPage({ filters: {}, limit: 10, offset: 0 })
).logs.map((log) => `${log.userId}:${log.action}`);
expect(left.sort()).toEqual([
"null:plugin_as_user",
"null:plugin_kv_set",
"user-1:plugin_db_client",
]);
expect(await repo.deleteRoutinePluginNoise()).toBe(0);
expect(writeCount).toBe(1);
});
it("keeps entries when their user is deleted, detaching instead of removing", async () => {
const repo = await createRepository();
await repo.create({
userId: "user-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
success: true,
timestamp: "2026-07-01T00:00:00.000Z",
});
await repo.create({
userId: "user-2",
username: "bob",
action: "create_host",
resourceType: "host",
resourceId: "8",
success: true,
timestamp: "2026-07-02T00:00:00.000Z",
});
expect(await repo.anonymizeByUserId("user-1")).toBe(1);
const { logs } = await repo.listPage({ filters: {}, limit: 10, offset: 0 });
expect(logs).toHaveLength(2);
const detached = logs.find((log) => log.action === "delete_host");
// The account is gone; the entry and its actor name are not.
expect(detached?.userId).toBeNull();
expect(detached?.username).toBe("alice");
expect(logs.find((log) => log.action === "create_host")?.userId).toBe(
"user-2",
);
});
it("reports nothing to detach for a user with no entries", async () => {
const repo = await createRepository();
expect(await repo.anonymizeByUserId("user-2")).toBe(0);
});
describe("pruning", () => {
async function countEntries(): Promise<number> {
const rows = await adapter!.query<{ count: number }>(
sql`SELECT COUNT(*) AS count FROM audit_logs`,
);
return Number(rows[0].count);
}
let writeSeq = 0;
beforeEach(() => {
writeSeq = 0;
});
async function write(repo: AuditLogRepository, n: number): Promise<void> {
for (let i = 0; i < n; i++) {
await repo.create({
userId: "user-1",
username: "alice",
action: "host_connect",
resourceType: "host",
success: true,
// Monotonic across calls so a second batch never reuses timestamps
// from the first, which would make "oldest first" ambiguous.
timestamp: new Date(
Date.UTC(2026, 0, 1, 0, 0, writeSeq++),
).toISOString(),
});
}
}
it("enforces the entry cap down to the target ratio", async () => {
process.env.AUDIT_LOG_MAX_ENTRIES = "10";
const repo = await createRepository();
await write(repo, 10);
await repo.pruneNow();
// Cap 10, target ratio 0.9 -> trimmed back to 9.
expect(await countEntries()).toBe(9);
});
it("drops the oldest entries first when over the cap", async () => {
process.env.AUDIT_LOG_MAX_ENTRIES = "10";
const repo = await createRepository();
await write(repo, 10);
await repo.pruneNow();
const rows = await adapter!.query<{ timestamp: string }>(
sql`SELECT timestamp FROM audit_logs ORDER BY timestamp ASC`,
);
// The first-written entry is the one discarded.
expect(rows[0].timestamp).toBe(
new Date(Date.UTC(2026, 0, 1, 0, 0, 1)).toISOString(),
);
});
it("removes entries past the retention window", async () => {
process.env.AUDIT_LOG_RETENTION_DAYS = "1";
const repo = await createRepository();
await repo.create({
username: "alice",
action: "old",
resourceType: "host",
success: true,
timestamp: new Date(Date.now() - 5 * 86_400_000).toISOString(),
});
await repo.create({
username: "alice",
action: "fresh",
resourceType: "host",
success: true,
timestamp: new Date().toISOString(),
});
await repo.pruneNow();
const rows = await adapter!.query<{ action: string }>(
sql`SELECT action FROM audit_logs`,
);
expect(rows.map((row) => row.action)).toEqual(["fresh"]);
});
it("keeps enforcing the cap across a burst of writes", async () => {
process.env.AUDIT_LOG_MAX_ENTRIES = "10";
const repo = await createRepository();
// The cap is checked on the write that crosses it, not on a timer, so a
// burst cannot run the table away past the ceiling.
await write(repo, 24);
expect(await countEntries()).toBeLessThanOrEqual(10);
});
it("re-reads the row count after entries are deleted elsewhere", async () => {
process.env.AUDIT_LOG_MAX_ENTRIES = "10";
const repo = await createRepository();
await write(repo, 9);
// Clears the table, so the cached count is now far too high.
await repo.deleteByUserId("user-1");
await write(repo, 9);
// Had the count kept counting up from 9, this second batch would have
// tripped the cap and pruned; a correct re-read leaves all 9 in place.
expect(await countEntries()).toBe(9);
});
it("keeps the write succeeding even when pruning cannot run", async () => {
process.env.AUDIT_LOG_MAX_ENTRIES = "not-a-number";
const repo = await createRepository();
await expect(write(repo, 1)).resolves.toBeUndefined();
expect(await countEntries()).toBe(1);
});
});
});