Skip to content

Commit 2de5a94

Browse files
y4nderclaude
andauthored
FAC-118 feat: add audit trail query endpoints (#282)
* feat: add audit trail query endpoints Add GET /audit-logs (paginated, filtered list) and GET /audit-logs/:id (single record) endpoints for superadmin audit log visibility. https://claude.ai/code/session_01D6jVaVQiXM5y8P8XmsmzG5 * fix: startup issue --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 080ebff commit 2de5a94

9 files changed

Lines changed: 858 additions & 2 deletions
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
import { NotFoundException } from '@nestjs/common';
2+
import { EntityManager } from '@mikro-orm/postgresql';
3+
import { Test, TestingModule } from '@nestjs/testing';
4+
import { AuditLog } from 'src/entities/audit-log.entity';
5+
import { AuditQueryService } from './audit-query.service';
6+
import { AuditAction } from './audit-action.enum';
7+
8+
describe('AuditQueryService', () => {
9+
let service: AuditQueryService;
10+
let em: {
11+
findAndCount: jest.Mock;
12+
findOneOrFail: jest.Mock;
13+
};
14+
15+
const sampleLog = {
16+
id: 'log-1',
17+
action: AuditAction.AUTH_LOGIN_SUCCESS,
18+
actorId: 'user-1',
19+
actorUsername: 'admin',
20+
resourceType: 'User',
21+
resourceId: 'user-1',
22+
metadata: { strategyUsed: 'LocalLoginStrategy' },
23+
browserName: 'Chrome',
24+
os: 'Linux',
25+
ipAddress: '127.0.0.1',
26+
occurredAt: new Date('2026-03-29T12:00:00.000Z'),
27+
} as AuditLog;
28+
29+
beforeEach(async () => {
30+
em = {
31+
findAndCount: jest.fn().mockResolvedValue([[sampleLog], 1]),
32+
findOneOrFail: jest.fn().mockResolvedValue(sampleLog),
33+
};
34+
35+
const module: TestingModule = await Test.createTestingModule({
36+
providers: [AuditQueryService, { provide: EntityManager, useValue: em }],
37+
}).compile();
38+
39+
service = module.get(AuditQueryService);
40+
});
41+
42+
it('should be defined', () => {
43+
expect(service).toBeDefined();
44+
});
45+
46+
describe('ListAuditLogs', () => {
47+
it('should return paginated results with correct meta', async () => {
48+
const result = await service.ListAuditLogs({ page: 1, limit: 10 });
49+
50+
expect(result.data).toHaveLength(1);
51+
expect(result.data[0].id).toBe('log-1');
52+
expect(result.data[0].action).toBe(AuditAction.AUTH_LOGIN_SUCCESS);
53+
expect(result.meta).toEqual({
54+
totalItems: 1,
55+
itemCount: 1,
56+
itemsPerPage: 10,
57+
totalPages: 1,
58+
currentPage: 1,
59+
});
60+
});
61+
62+
it('should pass softDelete: false filter', async () => {
63+
await service.ListAuditLogs({});
64+
65+
expect(em.findAndCount).toHaveBeenCalledWith(
66+
AuditLog,
67+
expect.any(Object),
68+
expect.objectContaining({
69+
filters: { softDelete: false },
70+
}),
71+
);
72+
});
73+
74+
it('should order by occurredAt DESC, id DESC', async () => {
75+
await service.ListAuditLogs({});
76+
77+
expect(em.findAndCount).toHaveBeenCalledWith(
78+
AuditLog,
79+
expect.any(Object),
80+
expect.objectContaining({
81+
orderBy: { occurredAt: 'DESC', id: 'DESC' },
82+
}),
83+
);
84+
});
85+
86+
it('should compute correct offset for pagination', async () => {
87+
await service.ListAuditLogs({ page: 3, limit: 15 });
88+
89+
expect(em.findAndCount).toHaveBeenCalledWith(
90+
AuditLog,
91+
expect.any(Object),
92+
expect.objectContaining({
93+
limit: 15,
94+
offset: 30,
95+
}),
96+
);
97+
});
98+
99+
it('should apply exact match filter for action', async () => {
100+
await service.ListAuditLogs({
101+
action: AuditAction.AUTH_LOGIN_SUCCESS,
102+
});
103+
104+
expect(em.findAndCount).toHaveBeenCalledWith(
105+
AuditLog,
106+
expect.objectContaining({
107+
action: AuditAction.AUTH_LOGIN_SUCCESS,
108+
}),
109+
expect.any(Object),
110+
);
111+
});
112+
113+
it('should apply exact match filter for actorId', async () => {
114+
await service.ListAuditLogs({ actorId: 'user-1' });
115+
116+
expect(em.findAndCount).toHaveBeenCalledWith(
117+
AuditLog,
118+
expect.objectContaining({ actorId: 'user-1' }),
119+
expect.any(Object),
120+
);
121+
});
122+
123+
it('should apply ILIKE partial match for actorUsername', async () => {
124+
await service.ListAuditLogs({ actorUsername: 'john' });
125+
126+
expect(em.findAndCount).toHaveBeenCalledWith(
127+
AuditLog,
128+
expect.objectContaining({
129+
actorUsername: { $ilike: '%john%' },
130+
}),
131+
expect.any(Object),
132+
);
133+
});
134+
135+
it('should apply exact match filter for resourceType', async () => {
136+
await service.ListAuditLogs({ resourceType: 'User' });
137+
138+
expect(em.findAndCount).toHaveBeenCalledWith(
139+
AuditLog,
140+
expect.objectContaining({ resourceType: 'User' }),
141+
expect.any(Object),
142+
);
143+
});
144+
145+
it('should apply exact match filter for resourceId', async () => {
146+
await service.ListAuditLogs({ resourceId: 'res-1' });
147+
148+
expect(em.findAndCount).toHaveBeenCalledWith(
149+
AuditLog,
150+
expect.objectContaining({ resourceId: 'res-1' }),
151+
expect.any(Object),
152+
);
153+
});
154+
155+
it('should apply date range filter with from only', async () => {
156+
await service.ListAuditLogs({ from: '2026-01-01T00:00:00.000Z' });
157+
158+
expect(em.findAndCount).toHaveBeenCalledWith(
159+
AuditLog,
160+
expect.objectContaining({
161+
occurredAt: { $gte: new Date('2026-01-01T00:00:00.000Z') },
162+
}),
163+
expect.any(Object),
164+
);
165+
});
166+
167+
it('should apply date range filter with to only', async () => {
168+
await service.ListAuditLogs({ to: '2026-12-31T23:59:59.999Z' });
169+
170+
expect(em.findAndCount).toHaveBeenCalledWith(
171+
AuditLog,
172+
expect.objectContaining({
173+
occurredAt: { $lte: new Date('2026-12-31T23:59:59.999Z') },
174+
}),
175+
expect.any(Object),
176+
);
177+
});
178+
179+
it('should apply date range filter with both from and to', async () => {
180+
await service.ListAuditLogs({
181+
from: '2026-01-01T00:00:00.000Z',
182+
to: '2026-12-31T23:59:59.999Z',
183+
});
184+
185+
expect(em.findAndCount).toHaveBeenCalledWith(
186+
AuditLog,
187+
expect.objectContaining({
188+
occurredAt: {
189+
$gte: new Date('2026-01-01T00:00:00.000Z'),
190+
$lte: new Date('2026-12-31T23:59:59.999Z'),
191+
},
192+
}),
193+
expect.any(Object),
194+
);
195+
});
196+
197+
it('should apply general text search across multiple fields', async () => {
198+
await service.ListAuditLogs({ search: 'login' });
199+
200+
expect(em.findAndCount).toHaveBeenCalledWith(
201+
AuditLog,
202+
expect.objectContaining({
203+
$or: [
204+
{ actorUsername: { $ilike: '%login%' } },
205+
{ action: { $ilike: '%login%' } },
206+
{ resourceType: { $ilike: '%login%' } },
207+
],
208+
}),
209+
expect.any(Object),
210+
);
211+
});
212+
213+
it('should escape LIKE special characters in actorUsername', async () => {
214+
await service.ListAuditLogs({ actorUsername: '100%_done' });
215+
216+
expect(em.findAndCount).toHaveBeenCalledWith(
217+
AuditLog,
218+
expect.objectContaining({
219+
actorUsername: { $ilike: '%100\\%\\_done%' },
220+
}),
221+
expect.any(Object),
222+
);
223+
});
224+
225+
it('should escape LIKE special characters in search', async () => {
226+
await service.ListAuditLogs({ search: '50%' });
227+
228+
expect(em.findAndCount).toHaveBeenCalledWith(
229+
AuditLog,
230+
expect.objectContaining({
231+
$or: [
232+
{ actorUsername: { $ilike: '%50\\%%' } },
233+
{ action: { $ilike: '%50\\%%' } },
234+
{ resourceType: { $ilike: '%50\\%%' } },
235+
],
236+
}),
237+
expect.any(Object),
238+
);
239+
});
240+
241+
it('should return empty data and zero meta when no results', async () => {
242+
em.findAndCount.mockResolvedValue([[], 0]);
243+
244+
const result = await service.ListAuditLogs({});
245+
246+
expect(result).toEqual({
247+
data: [],
248+
meta: {
249+
totalItems: 0,
250+
itemCount: 0,
251+
itemsPerPage: 10,
252+
totalPages: 0,
253+
currentPage: 1,
254+
},
255+
});
256+
});
257+
258+
it('should combine multiple filters', async () => {
259+
await service.ListAuditLogs({
260+
action: AuditAction.AUTH_LOGIN_SUCCESS,
261+
actorId: 'user-1',
262+
resourceType: 'User',
263+
});
264+
265+
expect(em.findAndCount).toHaveBeenCalledWith(
266+
AuditLog,
267+
{
268+
action: AuditAction.AUTH_LOGIN_SUCCESS,
269+
actorId: 'user-1',
270+
resourceType: 'User',
271+
},
272+
expect.any(Object),
273+
);
274+
});
275+
276+
it('should trim actorUsername before searching', async () => {
277+
await service.ListAuditLogs({ actorUsername: ' john ' });
278+
279+
expect(em.findAndCount).toHaveBeenCalledWith(
280+
AuditLog,
281+
expect.objectContaining({
282+
actorUsername: { $ilike: '%john%' },
283+
}),
284+
expect.any(Object),
285+
);
286+
});
287+
288+
it('should trim search before searching', async () => {
289+
await service.ListAuditLogs({ search: ' login ' });
290+
291+
expect(em.findAndCount).toHaveBeenCalledWith(
292+
AuditLog,
293+
expect.objectContaining({
294+
$or: [
295+
{ actorUsername: { $ilike: '%login%' } },
296+
{ action: { $ilike: '%login%' } },
297+
{ resourceType: { $ilike: '%login%' } },
298+
],
299+
}),
300+
expect.any(Object),
301+
);
302+
});
303+
304+
it('should use default page 1 and limit 10 when not specified', async () => {
305+
await service.ListAuditLogs({});
306+
307+
expect(em.findAndCount).toHaveBeenCalledWith(
308+
AuditLog,
309+
expect.any(Object),
310+
expect.objectContaining({
311+
offset: 0,
312+
limit: 10,
313+
}),
314+
);
315+
});
316+
});
317+
318+
describe('GetAuditLog', () => {
319+
it('should return a mapped audit log detail', async () => {
320+
const result = await service.GetAuditLog('log-1');
321+
322+
expect(result.id).toBe('log-1');
323+
expect(result.action).toBe(AuditAction.AUTH_LOGIN_SUCCESS);
324+
expect(result.actorId).toBe('user-1');
325+
expect(result.actorUsername).toBe('admin');
326+
expect(result.metadata).toEqual({
327+
strategyUsed: 'LocalLoginStrategy',
328+
});
329+
expect(result.occurredAt).toEqual(new Date('2026-03-29T12:00:00.000Z'));
330+
});
331+
332+
it('should pass softDelete: false filter to findOneOrFail', async () => {
333+
await service.GetAuditLog('log-1');
334+
335+
expect(em.findOneOrFail).toHaveBeenCalledWith(
336+
AuditLog,
337+
{ id: 'log-1' },
338+
expect.objectContaining({
339+
filters: { softDelete: false },
340+
}),
341+
);
342+
});
343+
344+
it('should throw NotFoundException when audit log does not exist', async () => {
345+
em.findOneOrFail.mockImplementation(
346+
(
347+
_entity: unknown,
348+
_where: unknown,
349+
opts: { failHandler: () => Error },
350+
) => {
351+
throw opts.failHandler();
352+
},
353+
);
354+
355+
await expect(service.GetAuditLog('non-existent')).rejects.toThrow(
356+
NotFoundException,
357+
);
358+
});
359+
});
360+
});

0 commit comments

Comments
 (0)