Skip to content

Commit 6cef43c

Browse files
feat(audit-log): add audit logging service and admin audit log page
Records admin actions (create/update/delete/disconnect, etc.) across the app and worker layers via a workspace-scoped AsyncLocalStorage actor, dispatched through a queued job, with an admin-facing page to browse and filter the log.
1 parent 53d4165 commit 6cef43c

216 files changed

Lines changed: 44897 additions & 1236 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { beforeEach, describe, expect, test, vi } from "vitest"
2+
3+
const mocks = vi.hoisted(() => ({
4+
auditRecord: vi.fn(),
5+
deleteObject: vi.fn(),
6+
findFirstGemini: vi.fn(),
7+
findFirstOpenai: vi.fn(),
8+
findOrFail: vi.fn(),
9+
insertReturning: vi.fn(),
10+
loggerWarn: vi.fn(),
11+
queueAdd: vi.fn(),
12+
txDeleteWhere: vi.fn(),
13+
}))
14+
15+
vi.mock("@/lib/safe-action", () => {
16+
const chain: Record<string, unknown> = {}
17+
chain.bindArgsSchemas = () => chain
18+
chain.inputSchema = () => chain
19+
chain.action = (fn: unknown) => fn
20+
return { workspaceActionClient: chain }
21+
})
22+
23+
vi.mock("@/features/common/schemas", () => ({
24+
workspaceIdrequestParams: [],
25+
}))
26+
27+
vi.mock("@/lib/log", () => ({
28+
logger: { warn: mocks.loggerWarn },
29+
}))
30+
31+
vi.mock("@chatbotx.io/business/audit", () => ({
32+
auditService: { record: mocks.auditRecord },
33+
}))
34+
35+
vi.mock("@chatbotx.io/business/errors", () => ({
36+
ChatbotXException: class ChatbotXException extends Error {},
37+
}))
38+
39+
vi.mock("@chatbotx.io/filesystem", () => ({
40+
uploader: { deleteObject: mocks.deleteObject },
41+
}))
42+
43+
vi.mock("@chatbotx.io/utils", () => ({
44+
createId: () => "file-1",
45+
zodBigintAsString: () => "mocked-schema",
46+
}))
47+
48+
vi.mock("@chatbotx.io/worker-config", () => ({
49+
AIJobAction: { processAIFile: "processAIFile" },
50+
aiAgentQueue: { add: mocks.queueAdd },
51+
}))
52+
53+
vi.mock("next-intl/server", () => ({
54+
getTranslations: vi.fn(async () => (key: string) => key),
55+
}))
56+
57+
vi.mock("../src/features/ai-files/schemas", () => ({
58+
createAIFileRequest: {},
59+
}))
60+
61+
vi.mock("@chatbotx.io/database/schema", () => ({
62+
aiEmbeddingModel: { id: "id" },
63+
aiFileModel: { id: "id" },
64+
}))
65+
66+
vi.mock("@chatbotx.io/database/client", () => ({
67+
db: {
68+
delete: vi.fn(() => ({ where: mocks.txDeleteWhere })),
69+
insert: vi.fn(() => ({
70+
values: vi.fn(() => ({ returning: mocks.insertReturning })),
71+
})),
72+
query: {
73+
integrationGeminiModel: { findFirst: mocks.findFirstGemini },
74+
integrationOpenaiModel: { findFirst: mocks.findFirstOpenai },
75+
},
76+
transaction: vi.fn(async (callback: (tx: unknown) => unknown) =>
77+
callback({ delete: vi.fn(() => ({ where: mocks.txDeleteWhere })) }),
78+
),
79+
},
80+
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
81+
findOrFail: mocks.findOrFail,
82+
}))
83+
84+
const { createAIFileAction } = await import(
85+
"@/features/ai-files/actions/create-ai-file.action"
86+
)
87+
const { deleteAIFile } = await import(
88+
"@/features/ai-files/actions/delete-ai-file.action"
89+
)
90+
91+
type ActionHandler<TParsedInput, TBindArgs extends unknown[]> = (props: {
92+
parsedInput: TParsedInput
93+
bindArgsParsedInputs: TBindArgs
94+
}) => Promise<unknown>
95+
96+
const workspaceId = "workspace-1"
97+
98+
beforeEach(() => {
99+
vi.clearAllMocks()
100+
mocks.findFirstOpenai.mockResolvedValue({ id: "openai-1" })
101+
mocks.findFirstGemini.mockResolvedValue(undefined)
102+
mocks.insertReturning.mockResolvedValue([{ id: "file-1" }])
103+
mocks.findOrFail.mockResolvedValue({ id: "file-1", path: "path/to/file" })
104+
})
105+
106+
describe("Knowledge tab audit messages", () => {
107+
test("createAIFileAction logs created a new Knowledge by id", async () => {
108+
await (
109+
createAIFileAction as unknown as ActionHandler<{ name: string }, [string]>
110+
)({
111+
parsedInput: { name: "manual.pdf" },
112+
bindArgsParsedInputs: [workspaceId],
113+
})
114+
115+
expect(mocks.auditRecord).toHaveBeenCalledWith({
116+
workspaceId,
117+
action: "create",
118+
detail: "created a new Knowledge (#file-1)",
119+
})
120+
})
121+
122+
test("deleteAIFile logs deleted a Knowledge by id", async () => {
123+
await deleteAIFile({ workspaceId, id: "file-1" })
124+
125+
expect(mocks.auditRecord).toHaveBeenCalledWith({
126+
workspaceId,
127+
action: "delete",
128+
detail: "deleted a Knowledge (#file-1)",
129+
})
130+
})
131+
132+
test("does not log the legacy AI Agent knowledge base message", async () => {
133+
await (
134+
createAIFileAction as unknown as ActionHandler<{ name: string }, [string]>
135+
)({
136+
parsedInput: { name: "manual.pdf" },
137+
bindArgsParsedInputs: [workspaceId],
138+
})
139+
await deleteAIFile({ workspaceId, id: "file-1" })
140+
141+
for (const call of mocks.auditRecord.mock.calls) {
142+
expect(call[0].detail).not.toContain(
143+
"updated the AI Agent knowledge base",
144+
)
145+
}
146+
})
147+
})
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// @vitest-environment node
2+
3+
import { beforeEach, describe, expect, test, vi } from "vitest"
4+
5+
const mocks = vi.hoisted(() => ({
6+
assertEnterpriseFeatures: vi.fn(),
7+
assertWorkspaceSuperAdmin: vi.fn(),
8+
listWorkspaceMembers: vi.fn(
9+
async (_input: unknown): Promise<unknown[]> => [],
10+
),
11+
findMany: vi.fn(async (_args: unknown) => []),
12+
count: vi.fn(async (_model: unknown, _where: unknown) => 0),
13+
relationsFilterToSQL: vi.fn((_model: unknown, where: unknown) => where),
14+
getPaginationWithDefaults: vi.fn((_input: unknown) => ({
15+
limit: 10,
16+
offset: 0,
17+
})),
18+
parseOrderByAsObject: vi.fn((_model: unknown, _input: unknown) => ({
19+
createdAt: "desc",
20+
})),
21+
}))
22+
23+
vi.mock("@chatbotx.io/business", () => ({
24+
assertEnterpriseFeatures: mocks.assertEnterpriseFeatures,
25+
workspaceMemberService: {
26+
listByWorkspaceId: (input: unknown) => mocks.listWorkspaceMembers(input),
27+
},
28+
}))
29+
30+
vi.mock("@chatbotx.io/database/schema", () => ({
31+
auditLogModel: { id: "id", createdAt: "createdAt" },
32+
}))
33+
34+
vi.mock("@chatbotx.io/database/client", () => ({
35+
db: {
36+
query: {
37+
auditLogModel: {
38+
findMany: (args: unknown) => mocks.findMany(args),
39+
},
40+
},
41+
$count: (model: unknown, where: unknown) => mocks.count(model, where),
42+
},
43+
relationsFilterToSQL: (model: unknown, where: unknown) =>
44+
mocks.relationsFilterToSQL(model, where),
45+
}))
46+
47+
vi.mock("@chatbotx.io/database/utils", async (importOriginal) => {
48+
const actual =
49+
await importOriginal<typeof import("@chatbotx.io/database/utils")>()
50+
return {
51+
...actual,
52+
getPaginationWithDefaults: (input: unknown) =>
53+
mocks.getPaginationWithDefaults(input),
54+
parseOrderByAsObject: (model: unknown, input: unknown) =>
55+
mocks.parseOrderByAsObject(model, input),
56+
}
57+
})
58+
59+
vi.mock("@/lib/auth/assert-workspace-super-admin", () => ({
60+
assertWorkspaceSuperAdmin: (workspaceId: string) =>
61+
mocks.assertWorkspaceSuperAdmin(workspaceId),
62+
}))
63+
64+
const { listAuditLogAdmins, listAuditLogs } = await import(
65+
"../src/enterprise/features/audit-logs/queries"
66+
)
67+
const { getDefaultAuditLogsRange, parseAuditLogsDateRange } = await import(
68+
"../src/enterprise/features/audit-logs/schemas/query"
69+
)
70+
71+
beforeEach(() => {
72+
vi.clearAllMocks()
73+
mocks.assertEnterpriseFeatures.mockResolvedValue(undefined)
74+
mocks.assertWorkspaceSuperAdmin.mockResolvedValue(undefined)
75+
mocks.listWorkspaceMembers.mockResolvedValue([])
76+
mocks.findMany.mockResolvedValue([])
77+
mocks.count.mockResolvedValue(0)
78+
})
79+
80+
describe("listAuditLogAdmins", () => {
81+
test("returns only super admins as user filter options", async () => {
82+
mocks.listWorkspaceMembers.mockResolvedValue([
83+
{
84+
permissions: { superAdmin: true },
85+
user: { id: "user-1", name: "Admin One", email: "one@example.com" },
86+
},
87+
{
88+
permissions: { superAdmin: false },
89+
user: { id: "user-2", name: "Member Two", email: "two@example.com" },
90+
},
91+
{
92+
permissions: { superAdmin: true },
93+
user: { id: "user-3", name: null, email: "three@example.com" },
94+
},
95+
])
96+
97+
await expect(listAuditLogAdmins("workspace-1")).resolves.toEqual([
98+
{ id: "user-1", label: "Admin One" },
99+
{ id: "user-3", label: "three@example.com" },
100+
])
101+
102+
expect(mocks.assertEnterpriseFeatures).toHaveBeenCalled()
103+
expect(mocks.assertWorkspaceSuperAdmin).toHaveBeenCalledWith("workspace-1")
104+
expect(mocks.listWorkspaceMembers).toHaveBeenCalledWith({
105+
workspaceId: "workspace-1",
106+
})
107+
})
108+
})
109+
110+
describe("audit log query schema", () => {
111+
test("defaults to a 90 day UTC date window", () => {
112+
expect(
113+
getDefaultAuditLogsRange(new Date("2026-08-16T10:30:00.000Z")),
114+
).toEqual({
115+
from: "2026-05-19",
116+
to: "2026-08-16",
117+
})
118+
})
119+
120+
test("falls back when from/to are invalid or reversed", () => {
121+
const parsed = parseAuditLogsDateRange(
122+
{
123+
from: "2026-08-16",
124+
to: "2026-05-19",
125+
},
126+
new Date("2026-08-16T10:30:00.000Z"),
127+
)
128+
129+
expect(parsed).toEqual(
130+
expect.objectContaining({
131+
from: "2026-05-19",
132+
to: "2026-08-16",
133+
}),
134+
)
135+
})
136+
137+
test("rejects malformed date keys and falls back to the default window", () => {
138+
const parsed = parseAuditLogsDateRange(
139+
{
140+
from: "2026-99-99",
141+
to: "not-a-date",
142+
},
143+
new Date("2026-08-16T10:30:00.000Z"),
144+
)
145+
146+
expect(parsed).toEqual(
147+
expect.objectContaining({
148+
from: "2026-05-19",
149+
to: "2026-08-16",
150+
}),
151+
)
152+
})
153+
154+
test("clamps future to dates to today", () => {
155+
const parsed = parseAuditLogsDateRange(
156+
{
157+
from: "2026-07-01",
158+
to: "2026-12-31",
159+
},
160+
new Date("2026-08-16T10:30:00.000Z"),
161+
)
162+
163+
expect(parsed).toEqual(
164+
expect.objectContaining({
165+
from: "2026-07-01",
166+
to: "2026-08-16",
167+
}),
168+
)
169+
})
170+
171+
test("clamps date ranges to a maximum 90 day window", () => {
172+
const parsed = parseAuditLogsDateRange(
173+
{
174+
from: "2025-01-01",
175+
to: "2026-08-16",
176+
},
177+
new Date("2026-08-16T10:30:00.000Z"),
178+
)
179+
180+
expect(parsed).toEqual(
181+
expect.objectContaining({
182+
from: "2026-05-19",
183+
to: "2026-08-16",
184+
}),
185+
)
186+
})
187+
})
188+
189+
describe("listAuditLogs", () => {
190+
test("requires enterprise and workspace super admin access", async () => {
191+
await listAuditLogs({
192+
workspaceId: "workspace-1",
193+
page: 1,
194+
perPage: 10,
195+
from: "2026-05-01",
196+
to: "2026-08-16",
197+
keyword: "",
198+
userId: "",
199+
sort: [{ id: "createdAt", desc: true }],
200+
})
201+
202+
expect(mocks.assertEnterpriseFeatures).toHaveBeenCalled()
203+
expect(mocks.assertWorkspaceSuperAdmin).toHaveBeenCalledWith("workspace-1")
204+
})
205+
206+
test("queries audit logs by workspace, bounded date range, user, and keyword", async () => {
207+
await listAuditLogs({
208+
workspaceId: "workspace-1",
209+
page: 1,
210+
perPage: 10,
211+
from: "2026-05-19",
212+
to: "2026-08-16",
213+
keyword: "member",
214+
userId: "user-1",
215+
sort: [{ id: "createdAt", desc: true }],
216+
})
217+
218+
expect(mocks.findMany).toHaveBeenCalledWith(
219+
expect.objectContaining({
220+
where: expect.objectContaining({
221+
workspaceId: "workspace-1",
222+
userId: "user-1",
223+
createdAt: {
224+
gte: new Date("2026-05-19T00:00:00.000Z"),
225+
lte: new Date("2026-08-16T23:59:59.999Z"),
226+
},
227+
OR: [
228+
{ action: { ilike: "%member%" } },
229+
{ detail: { ilike: "%member%" } },
230+
],
231+
}),
232+
orderBy: { createdAt: "desc", id: "desc" },
233+
}),
234+
)
235+
expect(mocks.relationsFilterToSQL).toHaveBeenCalledWith(
236+
{ id: "id", createdAt: "createdAt" },
237+
expect.objectContaining({
238+
workspaceId: "workspace-1",
239+
userId: "user-1",
240+
}),
241+
)
242+
})
243+
})

0 commit comments

Comments
 (0)