Skip to content

Commit c8beedd

Browse files
authored
Merge pull request #282 from Mathews-25/feat/issues-199-207-227-231
feat: resolve issues #199, #207, #227, #231
2 parents d040e65 + 6bdf0b3 commit c8beedd

16 files changed

Lines changed: 901 additions & 60 deletions

File tree

backend/src/admin/admin.controller.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ import {
1010
Query,
1111
UseGuards,
1212
Req,
13+
Res,
1314
HttpCode,
1415
HttpStatus,
1516
NotFoundException,
1617
} from '@nestjs/common';
1718
import { ConfigService } from '@nestjs/config';
1819
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
1920
import { IsEnum, IsOptional, IsString } from 'class-validator';
20-
import { Request } from 'express';
21+
import { Request, Response } from 'express';
2122
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
2223
import { AdminRoleGuard } from './guards/admin-role.guard';
2324
import { AdminService } from './admin.service';
@@ -86,13 +87,49 @@ export class AdminController {
8687
/**
8788
* GET /admin/audits
8889
*
89-
* Paginated read of the immutable admin audit log.
90+
* Cursor-paginated, filterable read of the immutable admin audit log.
91+
* Logs each access as a meta-audit entry.
9092
* Requires: admin role + valid JWT.
9193
*/
9294
@Get('audits')
93-
@ApiOperation({ summary: 'Paginated admin audit log' })
94-
async getAudits(@Query() query: AuditQueryDto) {
95-
return this.auditService.findAll(query.page, query.limit, query.action);
95+
@ApiOperation({ summary: 'Cursor-paginated admin audit log with filters' })
96+
async getAudits(@Query() query: AuditQueryDto, @Req() req: AdminRequest) {
97+
const actor = req.user?.walletAddress ?? 'unknown';
98+
// Meta-audit: log this access
99+
await this.auditService.write({
100+
actor,
101+
action: 'audit_log_read',
102+
payload: { cursor: query.cursor, limit: query.limit, action: query.action, actor: query.actor, from: query.from, to: query.to } as Record<string, unknown>,
103+
ipAddress: req.ip,
104+
});
105+
return this.auditService.findAll(query);
106+
}
107+
108+
/**
109+
* GET /admin/audits/export
110+
*
111+
* Streaming CSV export of the audit log for the given filters.
112+
* Logs each export as a meta-audit entry.
113+
* Requires: admin role + valid JWT.
114+
*/
115+
@Get('audits/export')
116+
@ApiOperation({ summary: 'Streaming CSV export of the audit log' })
117+
async exportAudits(
118+
@Query() query: AuditQueryDto,
119+
@Req() req: AdminRequest,
120+
@Res() res: Response,
121+
) {
122+
const actor = req.user?.walletAddress ?? 'unknown';
123+
await this.auditService.write({
124+
actor,
125+
action: 'audit_log_export',
126+
payload: { action: query.action, actor: query.actor, from: query.from, to: query.to } as Record<string, unknown>,
127+
ipAddress: req.ip,
128+
});
129+
await this.auditService.streamCsv(
130+
{ action: query.action, actor: query.actor, from: query.from, to: query.to },
131+
res,
132+
);
96133
}
97134

98135
/**
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { Test } from '@nestjs/testing';
2+
import { AuditService } from './audit.service';
3+
import { PrismaService } from '../prisma/prisma.service';
4+
5+
const FIXTURES = [
6+
{ id: 'a1', actor: 'GABC', action: 'reindex', ipAddress: '1.1.1.1', createdAt: new Date('2024-01-01T10:00:00Z'), payload: {} },
7+
{ id: 'a2', actor: 'GABC', action: 'pause', ipAddress: '1.1.1.1', createdAt: new Date('2024-01-02T10:00:00Z'), payload: {} },
8+
{ id: 'a3', actor: 'GXYZ', action: 'reindex', ipAddress: '2.2.2.2', createdAt: new Date('2024-01-03T10:00:00Z'), payload: {} },
9+
{ id: 'a4', actor: 'GXYZ', action: 'feature_flag_update', ipAddress: '2.2.2.2', createdAt: new Date('2024-01-04T10:00:00Z'), payload: {} },
10+
{ id: 'a5', actor: 'GABC', action: 'reindex', ipAddress: '1.1.1.1', createdAt: new Date('2024-01-05T10:00:00Z'), payload: {} },
11+
];
12+
13+
function buildPrismaMock() {
14+
return {
15+
adminAuditLog: {
16+
create: jest.fn(),
17+
findMany: jest.fn(),
18+
count: jest.fn(),
19+
},
20+
};
21+
}
22+
23+
describe('AuditService', () => {
24+
let service: AuditService;
25+
let prisma: ReturnType<typeof buildPrismaMock>;
26+
27+
beforeEach(async () => {
28+
prisma = buildPrismaMock();
29+
const module = await Test.createTestingModule({
30+
providers: [
31+
AuditService,
32+
{ provide: PrismaService, useValue: prisma },
33+
],
34+
}).compile();
35+
service = module.get(AuditService);
36+
});
37+
38+
// ── write ──────────────────────────────────────────────────────────────────
39+
40+
it('write creates a row', async () => {
41+
prisma.adminAuditLog.create.mockResolvedValue({});
42+
await service.write({ actor: 'GABC', action: 'test', payload: {} });
43+
expect(prisma.adminAuditLog.create).toHaveBeenCalledWith({
44+
data: { actor: 'GABC', action: 'test', payload: {} },
45+
});
46+
});
47+
48+
// ── findAll filters ────────────────────────────────────────────────────────
49+
50+
it('returns all rows when no filters', async () => {
51+
prisma.adminAuditLog.findMany.mockResolvedValue(FIXTURES.slice(0, 20));
52+
const result = await service.findAll({ limit: 20 });
53+
expect(prisma.adminAuditLog.findMany).toHaveBeenCalledWith(
54+
expect.objectContaining({ where: {} }),
55+
);
56+
expect(result.items).toHaveLength(FIXTURES.slice(0, 20).length);
57+
});
58+
59+
it('filters by action', async () => {
60+
const filtered = FIXTURES.filter((f) => f.action === 'reindex');
61+
prisma.adminAuditLog.findMany.mockResolvedValue(filtered);
62+
const result = await service.findAll({ action: 'reindex' });
63+
expect(prisma.adminAuditLog.findMany).toHaveBeenCalledWith(
64+
expect.objectContaining({ where: { action: 'reindex' } }),
65+
);
66+
expect(result.items).toEqual(filtered);
67+
});
68+
69+
it('filters by actor', async () => {
70+
const filtered = FIXTURES.filter((f) => f.actor === 'GABC');
71+
prisma.adminAuditLog.findMany.mockResolvedValue(filtered);
72+
const result = await service.findAll({ actor: 'GABC' });
73+
expect(prisma.adminAuditLog.findMany).toHaveBeenCalledWith(
74+
expect.objectContaining({ where: { actor: 'GABC' } }),
75+
);
76+
expect(result.items).toEqual(filtered);
77+
});
78+
79+
it('filters by date range', async () => {
80+
const filtered = FIXTURES.filter(
81+
(f) => f.createdAt >= new Date('2024-01-02T00:00:00Z') && f.createdAt <= new Date('2024-01-04T23:59:59Z'),
82+
);
83+
prisma.adminAuditLog.findMany.mockResolvedValue(filtered);
84+
const result = await service.findAll({ from: '2024-01-02T00:00:00Z', to: '2024-01-04T23:59:59Z' });
85+
expect(prisma.adminAuditLog.findMany).toHaveBeenCalledWith(
86+
expect.objectContaining({
87+
where: {
88+
createdAt: {
89+
gte: new Date('2024-01-02T00:00:00Z'),
90+
lte: new Date('2024-01-04T23:59:59Z'),
91+
},
92+
},
93+
}),
94+
);
95+
expect(result.items).toEqual(filtered);
96+
});
97+
98+
it('combines action + actor filters', async () => {
99+
const filtered = FIXTURES.filter((f) => f.action === 'reindex' && f.actor === 'GABC');
100+
prisma.adminAuditLog.findMany.mockResolvedValue(filtered);
101+
const result = await service.findAll({ action: 'reindex', actor: 'GABC' });
102+
expect(prisma.adminAuditLog.findMany).toHaveBeenCalledWith(
103+
expect.objectContaining({ where: { action: 'reindex', actor: 'GABC' } }),
104+
);
105+
expect(result.items).toEqual(filtered);
106+
});
107+
108+
// ── cursor pagination ──────────────────────────────────────────────────────
109+
110+
it('returns hasMore=false when results fit in one page', async () => {
111+
prisma.adminAuditLog.findMany.mockResolvedValue(FIXTURES.slice(0, 3));
112+
const result = await service.findAll({ limit: 5 });
113+
expect(result.hasMore).toBe(false);
114+
expect(result.nextCursor).toBeNull();
115+
});
116+
117+
it('returns hasMore=true and nextCursor when more rows exist', async () => {
118+
// take=2+1=3 rows returned, meaning there are more
119+
const page = [...FIXTURES.slice(0, 2), FIXTURES[2]];
120+
prisma.adminAuditLog.findMany.mockResolvedValue(page);
121+
const result = await service.findAll({ limit: 2 });
122+
expect(result.hasMore).toBe(true);
123+
expect(result.nextCursor).toBe(FIXTURES[1].id);
124+
expect(result.items).toHaveLength(2);
125+
});
126+
127+
it('passes cursor to prisma when provided', async () => {
128+
prisma.adminAuditLog.findMany.mockResolvedValue([]);
129+
await service.findAll({ cursor: 'a2', limit: 2 });
130+
expect(prisma.adminAuditLog.findMany).toHaveBeenCalledWith(
131+
expect.objectContaining({ cursor: { id: 'a2' }, skip: 1 }),
132+
);
133+
});
134+
135+
// ── streamCsv ──────────────────────────────────────────────────────────────
136+
137+
it('streams CSV rows and ends response', async () => {
138+
prisma.adminAuditLog.findMany
139+
.mockResolvedValueOnce([
140+
{ id: 'a1', actor: 'GABC', action: 'reindex', ipAddress: '1.1.1.1', createdAt: new Date('2024-01-01T10:00:00Z') },
141+
])
142+
.mockResolvedValueOnce([]); // second batch empty → done
143+
144+
const chunks: string[] = [];
145+
const res = {
146+
setHeader: jest.fn(),
147+
write: jest.fn((chunk: string) => chunks.push(chunk)),
148+
end: jest.fn(),
149+
} as unknown as import('express').Response;
150+
151+
await service.streamCsv({}, res);
152+
153+
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/csv');
154+
expect(chunks[0]).toBe('id,actor,action,ipAddress,createdAt\n');
155+
expect(chunks[1]).toContain('a1,GABC,reindex');
156+
expect(res.end).toHaveBeenCalled();
157+
});
158+
159+
it('CSV export matches JSON results for same filters', async () => {
160+
const rows = [
161+
{ id: 'a3', actor: 'GXYZ', action: 'reindex', ipAddress: '2.2.2.2', createdAt: new Date('2024-01-03T10:00:00Z') },
162+
];
163+
// findAll returns same row
164+
prisma.adminAuditLog.findMany
165+
.mockResolvedValueOnce([...rows, rows[0]]) // +1 for hasMore check in findAll
166+
.mockResolvedValueOnce(rows) // first batch in streamCsv
167+
.mockResolvedValueOnce([]); // second batch empty
168+
169+
const jsonResult = await service.findAll({ action: 'reindex', actor: 'GXYZ', limit: 1 });
170+
171+
const chunks: string[] = [];
172+
const res = {
173+
setHeader: jest.fn(),
174+
write: jest.fn((c: string) => chunks.push(c)),
175+
end: jest.fn(),
176+
} as unknown as import('express').Response;
177+
await service.streamCsv({ action: 'reindex', actor: 'GXYZ' }, res);
178+
179+
const csvLines = chunks.join('').split('\n').filter(Boolean).slice(1); // skip header
180+
expect(csvLines).toHaveLength(jsonResult.items.length);
181+
expect(csvLines[0]).toContain(jsonResult.items[0].id);
182+
});
183+
});

backend/src/admin/audit.service.ts

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Injectable } from '@nestjs/common';
22
import { Prisma } from '@prisma/client';
33
import { PrismaService } from '../prisma/prisma.service';
4+
import { Response } from 'express';
45

56
export interface AuditMeta {
67
actor: string;
@@ -9,6 +10,15 @@ export interface AuditMeta {
910
ipAddress?: string;
1011
}
1112

13+
export interface AuditFilters {
14+
cursor?: string;
15+
limit?: number;
16+
action?: string;
17+
actor?: string;
18+
from?: string;
19+
to?: string;
20+
}
21+
1222
@Injectable()
1323
export class AuditService {
1424
constructor(private readonly prisma: PrismaService) {}
@@ -17,17 +27,82 @@ export class AuditService {
1727
await this.prisma.adminAuditLog.create({ data: meta });
1828
}
1929

20-
async findAll(page: number, limit: number, action?: string) {
21-
const where = action ? { action } : {};
22-
const [items, total] = await Promise.all([
23-
this.prisma.adminAuditLog.findMany({
30+
async findAll(filters: AuditFilters) {
31+
const { cursor, limit = 20, action, actor, from, to } = filters;
32+
const take = Math.min(limit, 100);
33+
34+
const where: Prisma.AdminAuditLogWhereInput = {
35+
...(action && { action }),
36+
...(actor && { actor }),
37+
...(from || to
38+
? {
39+
createdAt: {
40+
...(from && { gte: new Date(from) }),
41+
...(to && { lte: new Date(to) }),
42+
},
43+
}
44+
: {}),
45+
};
46+
47+
const items = await this.prisma.adminAuditLog.findMany({
48+
where,
49+
orderBy: { createdAt: 'desc' },
50+
take: take + 1,
51+
...(cursor && { cursor: { id: cursor }, skip: 1 }),
52+
});
53+
54+
const hasMore = items.length > take;
55+
const page = hasMore ? items.slice(0, take) : items;
56+
const nextCursor = hasMore ? page[page.length - 1].id : null;
57+
58+
return { items: page, nextCursor, hasMore };
59+
}
60+
61+
async streamCsv(filters: Omit<AuditFilters, 'cursor' | 'limit'>, res: Response): Promise<void> {
62+
const { action, actor, from, to } = filters;
63+
64+
const where: Prisma.AdminAuditLogWhereInput = {
65+
...(action && { action }),
66+
...(actor && { actor }),
67+
...(from || to
68+
? {
69+
createdAt: {
70+
...(from && { gte: new Date(from) }),
71+
...(to && { lte: new Date(to) }),
72+
},
73+
}
74+
: {}),
75+
};
76+
77+
res.setHeader('Content-Type', 'text/csv');
78+
res.setHeader('Content-Disposition', 'attachment; filename="audit-log.csv"');
79+
res.write('id,actor,action,ipAddress,createdAt\n');
80+
81+
const BATCH = 500;
82+
let lastId: string | undefined;
83+
let done = false;
84+
85+
while (!done) {
86+
const rows = await this.prisma.adminAuditLog.findMany({
2487
where,
25-
orderBy: { createdAt: 'desc' },
26-
skip: (page - 1) * limit,
27-
take: limit,
28-
}),
29-
this.prisma.adminAuditLog.count({ where }),
30-
]);
31-
return { items, total, page, limit };
88+
orderBy: { createdAt: 'asc' },
89+
take: BATCH,
90+
...(lastId && { cursor: { id: lastId }, skip: 1 }),
91+
select: { id: true, actor: true, action: true, ipAddress: true, createdAt: true },
92+
});
93+
94+
for (const row of rows) {
95+
const ip = row.ipAddress ?? '';
96+
res.write(`${row.id},${row.actor},${row.action},${ip},${row.createdAt.toISOString()}\n`);
97+
}
98+
99+
if (rows.length < BATCH) {
100+
done = true;
101+
} else {
102+
lastId = rows[rows.length - 1].id;
103+
}
104+
}
105+
106+
res.end();
32107
}
33108
}

0 commit comments

Comments
 (0)