Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,118 @@ process.env.SSE_SUBSCRIPTION_TTL_MS = '86400000';
process.env.SSE_REPLAY_MAX_EVENTS = '1000';
process.env.SSE_PRUNE_INTERVAL_MS = '300000';

jest.mock('@prisma/client', () => {
const modelMocks: Record<string, any> = {};
let auditLogsStore: any[] = [];

const getModelMock = (modelName: string) => {
if (modelName === 'auditLog') {
if (!modelMocks.auditLog) {
modelMocks.auditLog = {
create: jest.fn().mockImplementation(async (args: any) => {
const entry = {
id: 'audit-' + Math.random().toString(36).substring(2, 9),
actorWallet: args?.data?.actorWallet || '',
actionType: args?.data?.actionType || '',
targetId: args?.data?.targetId ?? null,
payload: args?.data?.payload ?? null,
createdAt: args?.data?.createdAt ? new Date(args.data.createdAt) : new Date(),
};
auditLogsStore.push(entry);
return entry;
}),
findMany: jest.fn().mockImplementation(async (args?: any) => {
let results = [...auditLogsStore];
if (args?.where?.actionType) {
results = results.filter((r) => r.actionType === args.where.actionType);
}
if (args?.where?.createdAt) {
const { gte, lte } = args.where.createdAt;
if (gte) results = results.filter((r) => new Date(r.createdAt) >= new Date(gte));
if (lte) results = results.filter((r) => new Date(r.createdAt) <= new Date(lte));
}
results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
if (args?.cursor?.id) {
const idx = results.findIndex((r) => r.id === args.cursor.id);
if (idx !== -1) {
const skip = args.skip ?? 0;
results = results.slice(idx + skip);
}
}
if (args?.take) {
results = results.slice(0, args.take);
}
return results;
}),
findFirst: jest.fn().mockImplementation(async (args?: any) => {
let results = [...auditLogsStore];
if (args?.where?.actionType) {
results = results.filter((r) => r.actionType === args.where.actionType);
}
results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return results[0] ?? null;
}),
deleteMany: jest.fn().mockImplementation(async (_args?: any) => {
const count = auditLogsStore.length;
auditLogsStore.length = 0;
return { count };
}),
count: jest.fn().mockImplementation(async () => auditLogsStore.length),
};
}
return modelMocks.auditLog;
}

if (!modelMocks[modelName]) {
modelMocks[modelName] = {
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(null),
findUniqueOrThrow: jest.fn().mockResolvedValue({}),
create: jest.fn().mockImplementation(async (args: any) => ({ id: 'mock-id', ...args?.data })),
createMany: jest.fn().mockResolvedValue({ count: 0 }),
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
delete: jest.fn().mockResolvedValue({}),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
count: jest.fn().mockResolvedValue(0),
aggregate: jest.fn().mockResolvedValue({}),
groupBy: jest.fn().mockResolvedValue([]),
upsert: jest.fn().mockImplementation(async (args: any) => ({ id: 'mock-id', ...args?.create })),
};
}
return modelMocks[modelName];
};

const createProxyClient = (): any => {
const baseObj: any = {
$disconnect: jest.fn().mockResolvedValue(undefined),
$connect: jest.fn().mockResolvedValue(undefined),
$transaction: jest.fn().mockImplementation(async (cbOrArr: any) => {
if (typeof cbOrArr === 'function') {
return cbOrArr(mockPrismaClient);
}
return Promise.all(cbOrArr);
}),
$extends: jest.fn(() => mockPrismaClient),
};

return new Proxy(baseObj, {
get(target: any, prop: string) {
if (prop in target) return target[prop];
if (typeof prop === 'string' && !prop.startsWith('$')) {
return getModelMock(prop);
}
return undefined;
},
});
};

const mockPrismaClient = createProxyClient();

return {
PrismaClient: jest.fn(() => mockPrismaClient),
};
}, { virtual: true });

jest.setTimeout(30000);
16 changes: 14 additions & 2 deletions src/modules/admin/admin.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,19 @@ const UpdateCreatorMetadataSchema = z.object({
type UpdateCreatorMetadataInput = z.infer<typeof UpdateCreatorMetadataSchema>;

const GetAuditLogSchema = z.object({
limit: z.coerce.number().int().positive().max(100).optional().default(50),
limit: z
.preprocess(
(val) => (val === undefined || val === '' ? 50 : Number(val)),
z.number().int().positive().transform((val) => Math.min(val, 100))
),
cursor: z.string().optional(),
actionType: z.string().optional(),
fromDate: z.string().optional(),
toDate: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
});

type GetAuditLogInput = z.infer<typeof GetAuditLogSchema>;
Expand Down Expand Up @@ -347,14 +357,16 @@ export const httpGetAuditLog: AsyncController = async (
limit: input.limit,
cursor: input.cursor,
actionType: input.actionType,
fromDate: input.fromDate || input.from || input.startDate,
toDate: input.toDate || input.to || input.endDate,
});

sendSuccess(res, {
entries: result.entries,
pagination: {
limit: input.limit,
cursor: input.cursor,
nextCursor: result.nextCursor,
nextCursor: result.nextCursor ?? null,
hasMore: result.hasMore,
},
});
Expand Down
102 changes: 79 additions & 23 deletions src/modules/admin/audit-log-endpoint.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import request from 'supertest';
import app from '../../app';
import { prisma } from '../../utils/prisma.utils';
import { signWalletAccessToken } from '../../utils/jwt.utils';

import jwt from 'jsonwebtoken';
import { envConfig } from '../../config';

describe('GET /admin/audit-log Endpoint Integration Tests', () => {
let adminToken: string;

beforeAll(async () => {
// Create admin token
// Create admin token with admin role
const adminWallet = '0xadmintestwallet1111111111111111111111111';
adminToken = signWalletAccessToken(adminWallet, 'admin-sub', 3600);

// Override token payload to include admin role
// In real scenario, JWT would be issued with role: 'admin'
// For testing, we mock the verification to allow admin role
adminToken = jwt.sign(
{ wallet: adminWallet, sub: 'admin-sub', role: 'admin', adminId: 'admin-sub' },
envConfig.JWT_SECRET,
{ expiresIn: 3600, issuer: envConfig.JWT_ISSUER }
);
});

afterAll(async () => {
Expand All @@ -28,14 +30,14 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

describe('Authentication and Authorization', () => {
it('AC4: 403 returned for non-admin callers (missing JWT)', async () => {
const response = await request(app).get('/admin/audit-log');
const response = await request(app).get('/api/v1/admin/audit-log');

expect(response.status).toBe(401);
});

it('should require valid admin JWT', async () => {
const response = await request(app)
.get('/admin/audit-log')
.get('/api/v1/admin/audit-log')
.set('Authorization', 'Bearer invalid_token');

expect(response.status).toBe(401);
Expand Down Expand Up @@ -75,7 +77,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

it('should return audit log entries', async () => {
const response = await request(app)
.get('/admin/audit-log')
.get('/api/v1/admin/audit-log')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
Expand All @@ -86,7 +88,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

it('AC3: actionType filter correctly narrows results', async () => {
const response = await request(app)
.get('/admin/audit-log?actionType=protocol_fee_updated')
.get('/api/v1/admin/audit-log?actionType=protocol_fee_updated')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
Expand All @@ -99,7 +101,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

it('should return empty results for non-existent actionType filter', async () => {
const response = await request(app)
.get('/admin/audit-log?actionType=nonexistent_action')
.get('/api/v1/admin/audit-log?actionType=nonexistent_action')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
Expand All @@ -124,7 +126,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

it('should respect limit parameter', async () => {
const response = await request(app)
.get('/admin/audit-log?limit=3')
.get('/api/v1/admin/audit-log?limit=3')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
Expand All @@ -133,7 +135,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

it('should default to limit of 50 if not specified', async () => {
const response = await request(app)
.get('/admin/audit-log')
.get('/api/v1/admin/audit-log')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
Expand All @@ -142,7 +144,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

it('should cap limit at 100', async () => {
const response = await request(app)
.get('/admin/audit-log?limit=200')
.get('/api/v1/admin/audit-log?limit=200')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
Expand All @@ -151,7 +153,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

it('AC2: Entries returned sorted by createdAt descending', async () => {
const response = await request(app)
.get('/admin/audit-log?limit=10')
.get('/api/v1/admin/audit-log?limit=10')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
Expand All @@ -167,7 +169,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {
it('AC5: Cursor pagination returns correct next page', async () => {
// First page
const page1Response = await request(app)
.get('/admin/audit-log?limit=3')
.get('/api/v1/admin/audit-log?limit=3')
.set('Authorization', `Bearer ${adminToken}`);

expect(page1Response.status).toBe(200);
Expand All @@ -179,7 +181,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {
if (page1Response.body.data.pagination.hasMore) {
// Second page with cursor
const page2Response = await request(app)
.get(`/admin/audit-log?limit=3&cursor=${nextCursor}`)
.get(`/api/v1/admin/audit-log?limit=3&cursor=${nextCursor}`)
.set('Authorization', `Bearer ${adminToken}`);

expect(page2Response.status).toBe(200);
Expand Down Expand Up @@ -211,7 +213,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

it('AC4: Returns actorWallet, actionType, targetId, payload, and createdAt per entry', async () => {
const response = await request(app)
.get('/admin/audit-log?limit=1')
.get('/api/v1/admin/audit-log?limit=1')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
Expand All @@ -230,7 +232,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {

it('should include pagination metadata', async () => {
const response = await request(app)
.get('/admin/audit-log')
.get('/api/v1/admin/audit-log')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
Expand All @@ -244,26 +246,80 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => {
describe('Error Handling', () => {
it('should reject invalid limit parameter', async () => {
const response = await request(app)
.get('/admin/audit-log?limit=invalid')
.get('/api/v1/admin/audit-log?limit=invalid')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(400);
});

it('should reject negative limit', async () => {
const response = await request(app)
.get('/admin/audit-log?limit=-5')
.get('/api/v1/admin/audit-log?limit=-5')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(400);
});

it('should reject zero limit', async () => {
const response = await request(app)
.get('/admin/audit-log?limit=0')
.get('/api/v1/admin/audit-log?limit=0')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(400);
});
});

describe('Date Range Filtering and Immutability', () => {
beforeEach(async () => {
await prisma.auditLog.create({
data: {
actorWallet: '0xadmin1',
actionType: 'key_deprecated',
targetId: 'key_old',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
});

await prisma.auditLog.create({
data: {
actorWallet: '0xadmin1',
actionType: 'key_trading_paused',
targetId: 'key_mid',
createdAt: new Date('2026-06-01T00:00:00.000Z'),
},
});

await prisma.auditLog.create({
data: {
actorWallet: '0xadmin1',
actionType: 'position_frozen',
targetId: 'key_new',
createdAt: new Date('2026-09-01T00:00:00.000Z'),
},
});
});

it('filters audit log entries by date range (fromDate and toDate)', async () => {
const response = await request(app)
.get('/api/v1/admin/audit-log?fromDate=2026-05-01T00:00:00.000Z&toDate=2026-07-01T00:00:00.000Z')
.set('Authorization', `Bearer ${adminToken}`);

expect(response.status).toBe(200);
expect(response.body.data.entries.length).toBe(1);
expect(response.body.data.entries[0].actionType).toBe('key_trading_paused');
});

it('enforces immutability: returns 404/405 for POST, PUT, DELETE routes on audit-log', async () => {
const postRes = await request(app)
.post('/api/v1/admin/audit-log')
.set('Authorization', `Bearer ${adminToken}`)
.send({ actionType: 'test' });
expect([404, 405]).toContain(postRes.status);

const deleteRes = await request(app)
.delete('/api/v1/admin/audit-log/some-id')
.set('Authorization', `Bearer ${adminToken}`);
expect([404, 405]).toContain(deleteRes.status);
});
});
});
Loading
Loading