From a9295d3c1797a3c0aded7a28b3441906958b189b Mon Sep 17 00:00:00 2001 From: gospeltout Date: Sat, 26 Sep 2026 09:18:57 +0100 Subject: [PATCH 1/2] feat: add centralized audit log service for sensitive admin and contract operations --- jest.setup.ts | 119 +++++++++++++++--- src/modules/admin/admin.controllers.ts | 16 ++- .../audit-log-endpoint.integration.test.ts | 102 +++++++++++---- src/modules/admin/audit-log.service.ts | 20 +++ src/modules/index.ts | 1 - src/utils/bigint-serializer.utils.ts | 1 + 6 files changed, 218 insertions(+), 41 deletions(-) diff --git a/jest.setup.ts b/jest.setup.ts index a8011b9e..b2701d50 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -29,25 +29,114 @@ process.env.SSE_REPLAY_MAX_EVENTS = '1000'; process.env.SSE_PRUNE_INTERVAL_MS = '300000'; jest.mock('@prisma/client', () => { - const mockPrismaClient = { - creatorProfile: { - findMany: jest.fn().mockResolvedValue([]), - }, - activity: { - findMany: jest.fn().mockResolvedValue([]), - }, - $disconnect: jest.fn(), - $extends: jest.fn(() => ({ - creatorProfile: { - findMany: jest.fn().mockResolvedValue([]), - }, - activity: { + const modelMocks: Record = {}; + 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; }, - $disconnect: jest.fn(), - })), + }); }; + const mockPrismaClient = createProxyClient(); + return { PrismaClient: jest.fn(() => mockPrismaClient), }; diff --git a/src/modules/admin/admin.controllers.ts b/src/modules/admin/admin.controllers.ts index 8c7021ac..a4cc6b5f 100644 --- a/src/modules/admin/admin.controllers.ts +++ b/src/modules/admin/admin.controllers.ts @@ -25,9 +25,19 @@ const UpdateCreatorMetadataSchema = z.object({ type UpdateCreatorMetadataInput = z.infer; 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; @@ -347,6 +357,8 @@ 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, { @@ -354,7 +366,7 @@ export const httpGetAuditLog: AsyncController = async ( pagination: { limit: input.limit, cursor: input.cursor, - nextCursor: result.nextCursor, + nextCursor: result.nextCursor ?? null, hasMore: result.hasMore, }, }); diff --git a/src/modules/admin/audit-log-endpoint.integration.test.ts b/src/modules/admin/audit-log-endpoint.integration.test.ts index 8a89f348..6073ffde 100644 --- a/src/modules/admin/audit-log-endpoint.integration.test.ts +++ b/src/modules/admin/audit-log-endpoint.integration.test.ts @@ -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 () => { @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -244,7 +246,7 @@ 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); @@ -252,7 +254,7 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => { 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); @@ -260,10 +262,64 @@ describe('GET /admin/audit-log Endpoint Integration Tests', () => { 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); + }); + }); }); diff --git a/src/modules/admin/audit-log.service.ts b/src/modules/admin/audit-log.service.ts index a95ed8ff..991c0df6 100644 --- a/src/modules/admin/audit-log.service.ts +++ b/src/modules/admin/audit-log.service.ts @@ -34,6 +34,12 @@ export interface GetAuditLogsInput { limit?: number; cursor?: string; // id of last item from previous page actionType?: string; + fromDate?: string | Date; + toDate?: string | Date; + from?: string | Date; + to?: string | Date; + startDate?: string | Date; + endDate?: string | Date; } export interface AuditLogEntry { @@ -67,6 +73,20 @@ export async function getAuditLogs( where.actionType = input.actionType; } + const start = input.fromDate || input.from || input.startDate; + const end = input.toDate || input.to || input.endDate; + + if (start || end) { + const createdAtFilter: Record = {}; + if (start) { + createdAtFilter.gte = new Date(start); + } + if (end) { + createdAtFilter.lte = new Date(end); + } + where.createdAt = createdAtFilter; + } + // Cursor-based pagination: fetch by createdAt and id const entries = await prisma.auditLog.findMany({ where, diff --git a/src/modules/index.ts b/src/modules/index.ts index 78c545b8..7e5f9480 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -27,7 +27,6 @@ import followerRouter from './followers/follower.routes'; import protocolRouter from './protocol/protocol.routes'; import revenueRouter from './revenue/revenue.routes'; import stakerRouter from './revenue/staker-revenue.routes'; -import stakingRouter from './staking/vault.routes'; import portfolioRouter from './portfolio/portfolio.routes'; import stakingRouter from './staking/staking.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; diff --git a/src/utils/bigint-serializer.utils.ts b/src/utils/bigint-serializer.utils.ts index 9c9b0431..cc230837 100644 --- a/src/utils/bigint-serializer.utils.ts +++ b/src/utils/bigint-serializer.utils.ts @@ -47,6 +47,7 @@ export function safeJsonStringify(value: unknown, space?: number): string { */ export function sanitizeBigInts(value: unknown): unknown { if (typeof value === 'bigint') return value.toString(); + if (value instanceof Date) return value.toISOString(); if (Array.isArray(value)) return value.map(sanitizeBigInts); if (value !== null && typeof value === 'object') { return Object.fromEntries( From 49b6cc89d35b9e5bd579c0a3d1236897f1b68660 Mon Sep 17 00:00:00 2001 From: gospeltout Date: Sat, 26 Sep 2026 09:27:14 +0100 Subject: [PATCH 2/2] updated --- src/modules/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/index.ts b/src/modules/index.ts index 7e5f9480..4b8ad868 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -27,6 +27,7 @@ import followerRouter from './followers/follower.routes'; import protocolRouter from './protocol/protocol.routes'; import revenueRouter from './revenue/revenue.routes'; import stakerRouter from './revenue/staker-revenue.routes'; +import vaultRouter from './staking/vault.routes'; import portfolioRouter from './portfolio/portfolio.routes'; import stakingRouter from './staking/staking.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; @@ -76,7 +77,7 @@ router.use('/followers', routeBodySizeLimit('default'), followerRouter); router.use('/protocol', routeBodySizeLimit('default'), protocolRouter); router.use('/revenue', routeBodySizeLimit('default'), revenueRouter); router.use('/staker', routeBodySizeLimit('default'), stakerRouter); -router.use('/staking', routeBodySizeLimit('default'), stakingRouter); +router.use('/staking', routeBodySizeLimit('default'), vaultRouter); router.use('/portfolio', routeBodySizeLimit('default'), portfolioRouter); router.use('/watchlist', routeBodySizeLimit('default'), watchlistRouter); router.use('/investor/watchlist', routeBodySizeLimit('default'), watchlistRouter);