Skip to content

Commit 8f211c8

Browse files
author
Pip Build
committed
test(audit): add audit log route and middleware tests
API route tests cover auth, pagination, filtering (action, userId, date range), combined filters, 503 on missing DB, and 500 on DB error. Middleware tests cover action derivation, config diff computation, non-blocking behavior, response status gating, and enable/disable. Refs #123
1 parent de8f76f commit 8f211c8

2 files changed

Lines changed: 472 additions & 0 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/**
2+
* Tests for src/api/middleware/auditLog.js
3+
* Covers action derivation, config diff computation, and middleware behaviour.
4+
*/
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
6+
7+
vi.mock('../../../src/logger.js', () => ({
8+
info: vi.fn(),
9+
warn: vi.fn(),
10+
error: vi.fn(),
11+
}));
12+
13+
vi.mock('../../../src/modules/config.js', () => {
14+
let currentConfig = { auditLog: { enabled: true, retentionDays: 90 } };
15+
return {
16+
getConfig: vi.fn(() => currentConfig),
17+
_setTestConfig: (c) => {
18+
currentConfig = c;
19+
},
20+
};
21+
});
22+
23+
import {
24+
auditLogMiddleware,
25+
computeConfigDiff,
26+
deriveAction,
27+
} from '../../../src/api/middleware/auditLog.js';
28+
import { _setTestConfig } from '../../../src/modules/config.js';
29+
30+
describe('auditLog middleware', () => {
31+
afterEach(() => {
32+
vi.clearAllMocks();
33+
_setTestConfig({ auditLog: { enabled: true, retentionDays: 90 } });
34+
});
35+
36+
// ─── deriveAction ─────────────────────────────────────────────
37+
38+
describe('deriveAction', () => {
39+
it('should derive config.update for PUT config', () => {
40+
expect(deriveAction('PUT', '/api/v1/guilds/123/config')).toBe('config.update');
41+
});
42+
43+
it('should derive config.update for PATCH config', () => {
44+
expect(deriveAction('PATCH', '/api/v1/guilds/123/config')).toBe('config.update');
45+
});
46+
47+
it('should derive members.update for member operations', () => {
48+
expect(deriveAction('PATCH', '/api/v1/guilds/123/members/456')).toBe('members.update');
49+
});
50+
51+
it('should derive moderation.create for POST moderation', () => {
52+
expect(deriveAction('POST', '/api/v1/moderation/warn')).toBe('moderation.create');
53+
});
54+
55+
it('should handle unknown paths gracefully', () => {
56+
const action = deriveAction('POST', '/api/v1');
57+
expect(action).toBe('post.unknown');
58+
});
59+
60+
it('should derive guild.update for guild-level operations', () => {
61+
expect(deriveAction('PUT', '/api/v1/guilds/123')).toBe('guild.update');
62+
});
63+
});
64+
65+
// ─── computeConfigDiff ────────────────────────────────────────
66+
67+
describe('computeConfigDiff', () => {
68+
it('should detect changed keys', () => {
69+
const before = { ai: { enabled: true }, welcome: { enabled: false } };
70+
const after = { ai: { enabled: false }, welcome: { enabled: false } };
71+
72+
const diff = computeConfigDiff(before, after);
73+
expect(diff.before).toHaveProperty('ai');
74+
expect(diff.after).toHaveProperty('ai');
75+
expect(diff.before).not.toHaveProperty('welcome');
76+
});
77+
78+
it('should detect added keys', () => {
79+
const before = { ai: { enabled: true } };
80+
const after = { ai: { enabled: true }, newSection: { foo: 'bar' } };
81+
82+
const diff = computeConfigDiff(before, after);
83+
expect(diff.after).toHaveProperty('newSection');
84+
expect(diff.before.newSection).toBeUndefined();
85+
});
86+
87+
it('should detect removed keys', () => {
88+
const before = { ai: { enabled: true }, old: { x: 1 } };
89+
const after = { ai: { enabled: true } };
90+
91+
const diff = computeConfigDiff(before, after);
92+
expect(diff.before).toHaveProperty('old');
93+
expect(diff.after.old).toBeUndefined();
94+
});
95+
96+
it('should return empty diff when configs are identical', () => {
97+
const config = { ai: { enabled: true }, welcome: { enabled: false } };
98+
const diff = computeConfigDiff(config, config);
99+
expect(Object.keys(diff.before)).toHaveLength(0);
100+
expect(Object.keys(diff.after)).toHaveLength(0);
101+
});
102+
103+
it('should handle null/undefined inputs', () => {
104+
const diff = computeConfigDiff(null, { ai: true });
105+
expect(diff.after).toHaveProperty('ai');
106+
});
107+
});
108+
109+
// ─── middleware behaviour ─────────────────────────────────────
110+
111+
describe('middleware', () => {
112+
function createMockReq(method = 'POST', path = '/api/v1/guilds/123/config') {
113+
const listeners = {};
114+
return {
115+
method,
116+
path,
117+
originalUrl: path,
118+
body: { ai: { enabled: true } },
119+
user: { userId: 'user1' },
120+
authMethod: 'oauth',
121+
ip: '127.0.0.1',
122+
socket: { remoteAddress: '127.0.0.1' },
123+
app: {
124+
locals: {
125+
dbPool: {
126+
query: vi.fn().mockResolvedValue({}),
127+
},
128+
},
129+
},
130+
on: vi.fn((event, cb) => {
131+
listeners[event] = cb;
132+
}),
133+
_listeners: listeners,
134+
};
135+
}
136+
137+
function createMockRes(statusCode = 200) {
138+
const listeners = {};
139+
return {
140+
statusCode,
141+
on: vi.fn((event, cb) => {
142+
listeners[event] = cb;
143+
}),
144+
_listeners: listeners,
145+
};
146+
}
147+
148+
it('should skip non-mutating methods', () => {
149+
const middleware = auditLogMiddleware();
150+
const req = createMockReq('GET');
151+
const res = createMockRes();
152+
const next = vi.fn();
153+
154+
middleware(req, res, next);
155+
156+
expect(next).toHaveBeenCalledOnce();
157+
expect(res.on).not.toHaveBeenCalled();
158+
});
159+
160+
it('should call next immediately (non-blocking)', () => {
161+
const middleware = auditLogMiddleware();
162+
const req = createMockReq();
163+
const res = createMockRes();
164+
const next = vi.fn();
165+
166+
middleware(req, res, next);
167+
168+
expect(next).toHaveBeenCalledOnce();
169+
});
170+
171+
it('should register a finish listener on the response', () => {
172+
const middleware = auditLogMiddleware();
173+
const req = createMockReq();
174+
const res = createMockRes();
175+
const next = vi.fn();
176+
177+
middleware(req, res, next);
178+
179+
expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
180+
});
181+
182+
it('should insert audit entry on successful response finish', () => {
183+
const middleware = auditLogMiddleware();
184+
const req = createMockReq();
185+
const res = createMockRes(200);
186+
const next = vi.fn();
187+
188+
middleware(req, res, next);
189+
190+
// Simulate response finish
191+
const finishCb = res._listeners.finish;
192+
finishCb();
193+
194+
expect(req.app.locals.dbPool.query).toHaveBeenCalledWith(
195+
expect.stringContaining('INSERT INTO audit_logs'),
196+
expect.arrayContaining(['123', 'user1']),
197+
);
198+
});
199+
200+
it('should not insert audit entry on failed response (4xx)', () => {
201+
const middleware = auditLogMiddleware();
202+
const req = createMockReq();
203+
const res = createMockRes(400);
204+
const next = vi.fn();
205+
206+
middleware(req, res, next);
207+
208+
const finishCb = res._listeners.finish;
209+
finishCb();
210+
211+
expect(req.app.locals.dbPool.query).not.toHaveBeenCalled();
212+
});
213+
214+
it('should skip when auditLog is disabled in config', () => {
215+
_setTestConfig({ auditLog: { enabled: false } });
216+
217+
const middleware = auditLogMiddleware();
218+
const req = createMockReq();
219+
const res = createMockRes();
220+
const next = vi.fn();
221+
222+
middleware(req, res, next);
223+
224+
expect(next).toHaveBeenCalledOnce();
225+
expect(res.on).not.toHaveBeenCalled();
226+
});
227+
228+
it('should skip when dbPool is unavailable', () => {
229+
const middleware = auditLogMiddleware();
230+
const req = createMockReq();
231+
req.app.locals.dbPool = null;
232+
const res = createMockRes();
233+
const next = vi.fn();
234+
235+
middleware(req, res, next);
236+
237+
expect(next).toHaveBeenCalledOnce();
238+
expect(res.on).not.toHaveBeenCalled();
239+
});
240+
});
241+
});

0 commit comments

Comments
 (0)