Skip to content

Commit 7362367

Browse files
authored
Merge pull request #441 from a-malik-gh/feat/423-audit-compliance
feat: audit logging compliance — tamper evidence, JSON export, signed…
2 parents dcd8065 + 010571f commit 7362367

8 files changed

Lines changed: 685 additions & 33 deletions

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@
141141
"**/*.(t|j)s"
142142
],
143143
"coverageDirectory": "../coverage",
144-
"testEnvironment": "node"
144+
"testEnvironment": "node",
145+
"moduleNameMapper": {
146+
"^src/(.*)$": "<rootDir>/$1"
147+
}
145148
}
146149
}
Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,59 @@
11
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { AuditLog } from 'src/common/security/audit-log.entity';
24
import { AuditLogController } from './audit-log.controller';
35
import { AuditLogService } from './audit-log.service';
46

57
describe('AuditLogController', () => {
68
let controller: AuditLogController;
9+
let service: AuditLogService;
710

811
beforeEach(async () => {
912
const module: TestingModule = await Test.createTestingModule({
1013
controllers: [AuditLogController],
11-
providers: [AuditLogService],
14+
providers: [
15+
AuditLogService,
16+
{
17+
provide: getRepositoryToken(AuditLog),
18+
useValue: {
19+
findOne: jest.fn().mockResolvedValue(null),
20+
create: jest.fn((x) => x),
21+
save: jest.fn(async (x) => ({ id: 'test-id', ...x })),
22+
find: jest.fn().mockResolvedValue([]),
23+
findAndCount: jest.fn().mockResolvedValue([[], 0]),
24+
},
25+
},
26+
],
1227
}).compile();
1328

1429
controller = module.get<AuditLogController>(AuditLogController);
30+
service = module.get<AuditLogService>(AuditLogService);
1531
});
1632

1733
it('should be defined', () => {
1834
expect(controller).toBeDefined();
1935
});
36+
37+
it('exposes signed export link creation and redemption', async () => {
38+
const link = await controller.createSignedExportLink({
39+
from: '2024-01-01T00:00:00Z',
40+
to: '2024-01-31T23:59:59Z',
41+
format: 'json',
42+
});
43+
expect(link.url).toContain('/admin/audit/export/download?token=');
44+
45+
const token = new URL(link.url, 'http://x').searchParams.get('token')!;
46+
const doc = (await controller.downloadSignedExport(token)) as {
47+
filename: string;
48+
contentType: string;
49+
content: string;
50+
} | null;
51+
expect(doc).not.toBeNull();
52+
expect(doc!.contentType).toContain('application/json');
53+
});
54+
55+
it('returns a rejection payload for tampered tokens', async () => {
56+
const res = await controller.downloadSignedExport('abc.def');
57+
expect(res).toMatchObject({ statusCode: 403 });
58+
});
2059
});

src/audit-log/audit-log.controller.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,46 @@ export class AuditLogController {
7373
exportAuditLog(@Body() dateRange: { from: Date; to: Date }) {
7474
return this.auditLogService.exportAuditLog(dateRange);
7575
}
76+
77+
@Post('export/json')
78+
@ApiResponse({
79+
status: 200,
80+
description: 'JSON export with checksum validation',
81+
})
82+
exportAuditLogJson(@Body() dateRange: { from: Date; to: Date }) {
83+
return this.auditLogService.exportAuditLogJson(dateRange);
84+
}
85+
86+
@Post('export/link')
87+
@ApiResponse({
88+
status: 200,
89+
description: 'Time-limited signed download link for a CSV/JSON export',
90+
})
91+
async createSignedExportLink(
92+
@Body()
93+
body: {
94+
from: string | Date;
95+
to: string | Date;
96+
format?: 'csv' | 'json';
97+
ttlSeconds?: number;
98+
},
99+
) {
100+
const range = { from: new Date(body.from), to: new Date(body.to) };
101+
return this.auditLogService.createSignedExportLink(
102+
range,
103+
body.format ?? 'csv',
104+
body.ttlSeconds ?? 300,
105+
);
106+
}
107+
108+
@Get('export/download')
109+
@ApiResponse({ status: 200, description: 'Redeems a signed export token' })
110+
@ApiQuery({ name: 'token', required: true })
111+
async downloadSignedExport(@Query('token') token: string) {
112+
const doc = await this.auditLogService.downloadSignedExport(token);
113+
if (!doc) {
114+
return { statusCode: 403, message: 'Invalid or expired export link' };
115+
}
116+
return doc;
117+
}
76118
}

src/audit-log/audit-log.module.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { Module } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
3-
import { AuditLogService } from './audit-log.service';
3+
import { AuditLogService, AUDIT_WORM_SINK } from './audit-log.service';
44
import { AuditLogController } from './audit-log.controller';
5+
import { FileWormSink } from './file-worm-sink';
56
import { AuditLog } from 'src/common/security/audit-log.entity';
67

78
@Module({
89
imports: [TypeOrmModule.forFeature([AuditLog])],
9-
providers: [AuditLogService],
10+
providers: [
11+
AuditLogService,
12+
{
13+
provide: AUDIT_WORM_SINK,
14+
useFactory: () => new FileWormSink(),
15+
},
16+
],
1017
controllers: [AuditLogController],
1118
exports: [AuditLogService],
1219
})

0 commit comments

Comments
 (0)