Skip to content

Commit e334a43

Browse files
Merge pull request #1359 from BigBen-7/test/service-unit-tests-2
Add unit tests for audit-export, mfa, sensitive-operations, adaptive-ttl services
2 parents 473c6db + e0c00a5 commit e334a43

4 files changed

Lines changed: 896 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { AuditExportService } from './audit-export.service';
3+
import { AuditQueryService } from './audit-query.service';
4+
5+
describe('AuditExportService', () => {
6+
let service: AuditExportService;
7+
let queryService: { search: jest.Mock };
8+
9+
beforeEach(async () => {
10+
const module: TestingModule = await Test.createTestingModule({
11+
providers: [
12+
AuditExportService,
13+
{ provide: AuditQueryService, useValue: { search: jest.fn() } },
14+
],
15+
}).compile();
16+
17+
service = module.get<AuditExportService>(AuditExportService);
18+
queryService = module.get(AuditQueryService);
19+
});
20+
21+
describe('exportToJson', () => {
22+
it('queries with the given filters and returns pretty-printed JSON', async () => {
23+
const logs = [{ id: '1', action: 'LOGIN' }];
24+
queryService.search.mockResolvedValue({ data: logs, total: 1 });
25+
26+
const result = await service.exportToJson({ userId: 'u1' } as any);
27+
28+
expect(queryService.search).toHaveBeenCalledWith({ userId: 'u1' }, 1, 10000);
29+
expect(JSON.parse(result)).toEqual(logs);
30+
expect(result).toContain('\n'); // pretty-printed, not minified
31+
});
32+
33+
it('returns an empty array literal when there are no matching logs', async () => {
34+
queryService.search.mockResolvedValue({ data: [], total: 0 });
35+
36+
const result = await service.exportToJson({} as any);
37+
38+
expect(result).toBe('[]');
39+
});
40+
41+
it('propagates a query failure', async () => {
42+
const error = new Error('query failed');
43+
queryService.search.mockRejectedValue(error);
44+
45+
await expect(service.exportToJson({} as any)).rejects.toThrow(error);
46+
});
47+
});
48+
49+
describe('exportToCsv', () => {
50+
it('emits a header row followed by one row per log', async () => {
51+
const timestamp = new Date('2026-01-01T00:00:00.000Z');
52+
queryService.search.mockResolvedValue({
53+
data: [
54+
{
55+
timestamp,
56+
userId: 'u1',
57+
userEmail: 'u1@example.com',
58+
action: 'LOGIN',
59+
category: 'auth',
60+
severity: 'info',
61+
entityType: 'user',
62+
entityId: 'u1',
63+
description: 'User logged in',
64+
ipAddress: '127.0.0.1',
65+
userAgent: 'jest',
66+
apiEndpoint: '/login',
67+
httpMethod: 'POST',
68+
statusCode: 200,
69+
},
70+
],
71+
});
72+
73+
const result = await service.exportToCsv({} as any);
74+
const lines = result.split('\n');
75+
76+
expect(lines[0]).toBe(
77+
'timestamp,userId,userEmail,action,category,severity,entityType,entityId,description,ipAddress,userAgent,apiEndpoint,httpMethod,statusCode',
78+
);
79+
expect(lines[1]).toBe(
80+
`${timestamp.toISOString()},u1,u1@example.com,LOGIN,auth,info,user,u1,User logged in,127.0.0.1,jest,/login,POST,200`,
81+
);
82+
});
83+
84+
it('substitutes empty strings for missing optional fields', async () => {
85+
queryService.search.mockResolvedValue({
86+
data: [
87+
{
88+
timestamp: new Date('2026-01-01T00:00:00.000Z'),
89+
action: 'LOGIN',
90+
category: 'auth',
91+
severity: 'info',
92+
},
93+
],
94+
});
95+
96+
const result = await service.exportToCsv({} as any);
97+
const dataLine = result.split('\n')[1];
98+
99+
// userId, userEmail, entityType, entityId, description, ipAddress,
100+
// userAgent, apiEndpoint, httpMethod, statusCode all fall back to ''.
101+
expect(dataLine).toBe(
102+
`${new Date('2026-01-01T00:00:00.000Z').toISOString()},,,LOGIN,auth,info,,,,,,,,`,
103+
);
104+
});
105+
106+
it('quotes and escapes fields containing commas, quotes, or newlines', async () => {
107+
queryService.search.mockResolvedValue({
108+
data: [
109+
{
110+
timestamp: new Date('2026-01-01T00:00:00.000Z'),
111+
action: 'LOGIN',
112+
category: 'auth',
113+
severity: 'info',
114+
description: 'Said "hello", then\nleft',
115+
},
116+
],
117+
});
118+
119+
const result = await service.exportToCsv({} as any);
120+
expect(result).toContain('"Said ""hello"", then\nleft"');
121+
});
122+
123+
it('returns just the header row when there are no matching logs', async () => {
124+
queryService.search.mockResolvedValue({ data: [] });
125+
126+
const result = await service.exportToCsv({} as any);
127+
128+
expect(result.split('\n')).toHaveLength(1);
129+
expect(result).toContain('timestamp,userId');
130+
});
131+
132+
it('propagates a query failure', async () => {
133+
const error = new Error('query failed');
134+
queryService.search.mockRejectedValue(error);
135+
136+
await expect(service.exportToCsv({} as any)).rejects.toThrow(error);
137+
});
138+
});
139+
});
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { SensitiveOperationsService } from './sensitive-operations.service';
3+
import { AuditLogService } from '../audit-log.service';
4+
import { AuditAction, AuditCategory, AuditSeverity } from '../enums/audit-action.enum';
5+
6+
describe('SensitiveOperationsService', () => {
7+
let service: SensitiveOperationsService;
8+
let auditLogService: { log: jest.Mock };
9+
10+
beforeEach(async () => {
11+
const module: TestingModule = await Test.createTestingModule({
12+
providers: [
13+
SensitiveOperationsService,
14+
{ provide: AuditLogService, useValue: { log: jest.fn().mockResolvedValue(undefined) } },
15+
],
16+
}).compile();
17+
18+
service = module.get<SensitiveOperationsService>(SensitiveOperationsService);
19+
auditLogService = module.get(AuditLogService);
20+
});
21+
22+
describe('logSensitiveOperation', () => {
23+
const baseOperation = {
24+
userId: 'u1',
25+
userEmail: 'u1@example.com',
26+
action: AuditAction.USER_DELETED,
27+
entityType: 'User',
28+
entityId: 'u2',
29+
description: 'deleted',
30+
ipAddress: '127.0.0.1',
31+
userAgent: 'jest',
32+
};
33+
34+
it('logs with WARNING severity, a resolved category, and the sensitive-operation flag', async () => {
35+
await service.logSensitiveOperation(baseOperation);
36+
37+
expect(auditLogService.log).toHaveBeenCalledWith(
38+
expect.objectContaining({
39+
userId: 'u1',
40+
action: AuditAction.USER_DELETED,
41+
category: AuditCategory.AUTHORIZATION,
42+
severity: AuditSeverity.WARNING,
43+
metadata: expect.objectContaining({ isSensitiveOperation: true }),
44+
}),
45+
);
46+
});
47+
48+
it('falls back to the SYSTEM category for an action with no explicit mapping', async () => {
49+
await service.logSensitiveOperation({
50+
...baseOperation,
51+
action: 'UNMAPPED_ACTION' as AuditAction,
52+
});
53+
54+
expect(auditLogService.log).toHaveBeenCalledWith(
55+
expect.objectContaining({ category: AuditCategory.SYSTEM }),
56+
);
57+
});
58+
59+
it('logs the error and rethrows when the underlying audit log write fails', async () => {
60+
const error = new Error('write failed');
61+
auditLogService.log.mockRejectedValue(error);
62+
63+
await expect(service.logSensitiveOperation(baseOperation)).rejects.toThrow(error);
64+
});
65+
});
66+
67+
describe('logUserDeletion', () => {
68+
it('delegates to logSensitiveOperation with a USER_DELETED entry', async () => {
69+
await service.logUserDeletion('u1', 'admin@x.com', 'u2', 'victim@x.com', '1.2.3.4', 'ua');
70+
71+
expect(auditLogService.log).toHaveBeenCalledWith(
72+
expect.objectContaining({
73+
action: AuditAction.USER_DELETED,
74+
entityType: 'User',
75+
entityId: 'u2',
76+
description: expect.stringContaining('victim@x.com'),
77+
}),
78+
);
79+
});
80+
});
81+
82+
describe('logRoleChange', () => {
83+
it('records the old and new role in oldValues/newValues', async () => {
84+
await service.logRoleChange(
85+
'u1',
86+
'admin@x.com',
87+
'u2',
88+
'target@x.com',
89+
'student',
90+
'instructor',
91+
'1.2.3.4',
92+
'ua',
93+
);
94+
95+
expect(auditLogService.log).toHaveBeenCalledWith(
96+
expect.objectContaining({
97+
action: AuditAction.USER_ROLE_CHANGED,
98+
oldValues: { role: 'student' },
99+
newValues: { role: 'instructor' },
100+
}),
101+
);
102+
});
103+
});
104+
105+
describe('logPasswordChange', () => {
106+
it('logs a PASSWORD_CHANGE entry scoped to the user themselves', async () => {
107+
await service.logPasswordChange('u1', 'u1@example.com', '1.2.3.4', 'ua');
108+
109+
expect(auditLogService.log).toHaveBeenCalledWith(
110+
expect.objectContaining({
111+
action: AuditAction.PASSWORD_CHANGE,
112+
entityId: 'u1',
113+
}),
114+
);
115+
});
116+
});
117+
118+
describe('logConfigChange', () => {
119+
it('logs directly with CRITICAL severity and the old/new config values', async () => {
120+
await service.logConfigChange(
121+
'u1',
122+
'admin@x.com',
123+
'feature.flag',
124+
false,
125+
true,
126+
'1.2.3.4',
127+
'ua',
128+
);
129+
130+
expect(auditLogService.log).toHaveBeenCalledWith(
131+
expect.objectContaining({
132+
action: AuditAction.CONFIG_CHANGED,
133+
category: AuditCategory.SYSTEM,
134+
severity: AuditSeverity.CRITICAL,
135+
oldValues: { value: false },
136+
newValues: { value: true },
137+
}),
138+
);
139+
});
140+
141+
it('propagates a failure from the underlying audit log write', async () => {
142+
const error = new Error('write failed');
143+
auditLogService.log.mockRejectedValue(error);
144+
145+
await expect(
146+
service.logConfigChange('u1', 'a@x.com', 'k', 1, 2, '1.2.3.4', 'ua'),
147+
).rejects.toThrow(error);
148+
});
149+
});
150+
151+
describe('logDataExport', () => {
152+
it('describes the export and includes record count/format in metadata', async () => {
153+
await service.logDataExport('u1', 'u1@x.com', 'User', 42, 'csv', '1.2.3.4', 'ua');
154+
155+
expect(auditLogService.log).toHaveBeenCalledWith(
156+
expect.objectContaining({
157+
action: AuditAction.DATA_EXPORTED,
158+
description: expect.stringContaining('42'),
159+
metadata: expect.objectContaining({ recordCount: 42, exportFormat: 'csv' }),
160+
}),
161+
);
162+
});
163+
});
164+
165+
describe('logBackupOperation', () => {
166+
it('maps CREATE to BACKUP_CREATED', async () => {
167+
await service.logBackupOperation('u1', 'a@x.com', 'CREATE', 'backup-1', '1.2.3.4', 'ua');
168+
169+
expect(auditLogService.log).toHaveBeenCalledWith(
170+
expect.objectContaining({ action: AuditAction.BACKUP_CREATED, entityId: 'backup-1' }),
171+
);
172+
});
173+
174+
it('maps RESTORE to BACKUP_RESTORED', async () => {
175+
await service.logBackupOperation('u1', 'a@x.com', 'RESTORE', 'backup-1', '1.2.3.4', 'ua');
176+
177+
expect(auditLogService.log).toHaveBeenCalledWith(
178+
expect.objectContaining({ action: AuditAction.BACKUP_RESTORED }),
179+
);
180+
});
181+
});
182+
183+
describe('logPermissionDenied', () => {
184+
it('logs with SECURITY category and WARNING severity', async () => {
185+
await service.logPermissionDenied('u1', 'u1@x.com', 'Course', 'delete', '1.2.3.4', 'ua');
186+
187+
expect(auditLogService.log).toHaveBeenCalledWith(
188+
expect.objectContaining({
189+
action: AuditAction.PERMISSION_DENIED,
190+
category: AuditCategory.SECURITY,
191+
severity: AuditSeverity.WARNING,
192+
}),
193+
);
194+
});
195+
196+
it('converts a null userId/userEmail to undefined for anonymous attempts', async () => {
197+
await service.logPermissionDenied(null, null, 'Course', 'delete', '1.2.3.4', 'ua');
198+
199+
expect(auditLogService.log).toHaveBeenCalledWith(
200+
expect.objectContaining({ userId: undefined, userEmail: undefined }),
201+
);
202+
});
203+
});
204+
205+
describe('logSuspiciousActivity', () => {
206+
it('logs with SECURITY category and CRITICAL severity, merging extra metadata', async () => {
207+
await service.logSuspiciousActivity(
208+
'u1',
209+
'u1@x.com',
210+
'brute-force',
211+
'multiple failed logins',
212+
'1.2.3.4',
213+
'ua',
214+
undefined,
215+
{ attempts: 5 },
216+
);
217+
218+
expect(auditLogService.log).toHaveBeenCalledWith(
219+
expect.objectContaining({
220+
action: AuditAction.SUSPICIOUS_ACTIVITY,
221+
category: AuditCategory.SECURITY,
222+
severity: AuditSeverity.CRITICAL,
223+
metadata: expect.objectContaining({ activityType: 'brute-force', attempts: 5 }),
224+
}),
225+
);
226+
});
227+
228+
it('supports an anonymous actor', async () => {
229+
await service.logSuspiciousActivity(
230+
null,
231+
null,
232+
'scraping',
233+
'unusual request pattern',
234+
'1.2.3.4',
235+
'ua',
236+
);
237+
238+
expect(auditLogService.log).toHaveBeenCalledWith(
239+
expect.objectContaining({ userId: undefined, userEmail: undefined }),
240+
);
241+
});
242+
});
243+
});

0 commit comments

Comments
 (0)