Files
Epicnabbo-Catalogus-Updated…/src/lib/services/audit.test.ts
T

258 lines
6.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const insertValues = vi.hoisted(() => vi.fn());
const selectRows = vi.hoisted(() => vi.fn());
const selectCount = vi.hoisted(() => vi.fn());
const selectUsers = vi.hoisted(() => vi.fn());
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
select: vi.fn((fields?: { value?: unknown; id?: unknown }) => {
// count() query passes { value: count() }; user lookup passes { id, username }
if (fields && "value" in fields && !("id" in fields)) {
return {
from: () => ({
where: () => selectCount(),
}),
};
}
if (fields && "id" in fields && "username" in fields) {
return {
from: () => ({
where: () => selectUsers(),
}),
};
}
// default: audit log rows
return {
from: () => ({
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => selectRows(),
}),
}),
}),
}),
};
}),
},
AdminAuditLog: {
id: "id",
userId: "userId",
action: "action",
target: "target",
},
User: { id: "id", username: "username" },
}));
vi.mock("@/env", () => ({ env: {} }));
import { getAuditLogs, logAudit } from "./audit";
beforeEach(() => {
vi.clearAllMocks();
});
describe("logAudit", () => {
it("creates an audit entry", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "test_action",
target: "user",
targetId: 42,
});
const data = insertValues.mock.calls[0][0];
expect(data.userId).toBe(1);
expect(data.action).toBe("test_action");
expect(data.target).toBe("user");
expect(data.targetId).toBe(42);
});
it("redacts sensitive keys in payload", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "update",
target: "user",
targetId: 1,
before: { username: "foo", password: "secret123" },
after: { username: "bar", password: "newsecret" },
});
const data = insertValues.mock.calls[0][0];
expect(JSON.parse(data.before).password).toBe("[Redacted]");
expect(JSON.parse(data.after).password).toBe("[Redacted]");
expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" });
});
it("redacts sensitive keys recursively in nested objects and arrays", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "update",
target: "user",
before: {
profile: { authTicket: "private-ticket" },
integrations: [{ api_key: "private-key" }],
},
});
const before = JSON.parse(insertValues.mock.calls[0][0].before);
expect(before.profile.authTicket).toBe("[Redacted]");
expect(before.integrations[0].api_key).toBe("[Redacted]");
});
it("persists correlation, domain, outcome, reason, and IP evidence", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "ban",
target: "user",
correlationId: "corr-123",
domain: "people",
outcome: "denied",
reason: "Policy requirement was not met",
ipAddress: "127.0.0.1",
});
expect(insertValues.mock.calls[0][0]).toMatchObject({
correlationId: "corr-123",
domain: "people",
outcome: "denied",
reason: "Policy requirement was not met",
ipAddress: "127.0.0.1",
});
});
it("writes through the injected transaction when one is supplied", async () => {
const transactionValues = vi.fn().mockResolvedValue({ id: 1 });
const transactionInsert = vi.fn(() => ({ values: transactionValues }));
await logAudit({ userId: 1, action: "update", target: "settings" }, {
insert: transactionInsert,
} as never);
expect(transactionInsert).toHaveBeenCalledOnce();
expect(transactionValues).toHaveBeenCalledOnce();
expect(insertValues).not.toHaveBeenCalled();
});
it("omits diff when only before or after is missing", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "delete",
target: "user",
before: { username: "foo" },
});
const data = insertValues.mock.calls[0][0];
expect(data.diff).toBeNull();
});
it("handles empty payloads", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({ userId: 1, action: "view", target: "page" });
expect(insertValues).toHaveBeenCalledOnce();
});
it("flattens nested objects", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "update_settings",
target: "user",
before: { nested: { key: "val" } },
after: {},
});
const data = insertValues.mock.calls[0][0];
expect(JSON.parse(data.before)).toEqual({ nested: { key: "val" } });
});
});
describe("getAuditLogs", () => {
it("returns paginated logs with usernames", async () => {
selectRows.mockResolvedValue([
{
id: 1,
userId: 1,
action: "test",
target: "user",
targetId: null,
before: null,
after: null,
diff: null,
createdAt: "2024-01-01",
},
{
id: 2,
userId: 2,
action: "test2",
target: "room",
targetId: null,
before: null,
after: null,
diff: null,
createdAt: "2024-01-02",
},
]);
selectCount.mockResolvedValue([{ value: 2 }]);
selectUsers.mockResolvedValue([
{ id: 1, username: "alice" },
{ id: 2, username: "bob" },
]);
const result = await getAuditLogs({ page: 1, perPage: 20 });
expect(result.rows).toHaveLength(2);
expect(result.rows[0].username).toBe("alice");
expect(result.rows[1].username).toBe("bob");
expect(result.total).toBe(2);
expect(result.lastPage).toBe(1);
});
it("filters by search term", async () => {
selectRows.mockResolvedValue([]);
selectCount.mockResolvedValue([{ value: 0 }]);
await getAuditLogs({ search: "test" });
expect(selectRows).toHaveBeenCalled();
expect(selectCount).toHaveBeenCalled();
});
it("falls back to User #id for unknown users", async () => {
selectRows.mockResolvedValue([
{
id: 1,
userId: 99,
action: "x",
target: "y",
targetId: null,
before: null,
after: null,
diff: null,
createdAt: "2024-01-01",
},
]);
selectCount.mockResolvedValue([{ value: 1 }]);
selectUsers.mockResolvedValue([]);
const result = await getAuditLogs();
expect(result.rows[0].username).toBe("User #99");
});
it("fails closed beyond the redaction depth cap without mutating the input", async () => {
const sentinel = "raw-depth-secret";
const deep = {
a: { b: { c: { d: { e: { f: { g: { secret: sentinel } } } } } } },
};
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "update",
target: "user",
before: { deep, state: "before" },
after: { deep, state: "after" },
});
const data = insertValues.mock.calls[0][0];
expect(`${data.before}${data.after}${data.diff}`).not.toContain(sentinel);
expect(deep.a.b.c.d.e.f.g.secret).toBe(sentinel);
});
});