From afc811de6d115167c5c5350c681923665e6fb5e7 Mon Sep 17 00:00:00 2001 From: 0xNinx Date: Wed, 29 Jul 2026 16:08:16 +0100 Subject: [PATCH 1/6] Utilized-stake accounting invariants in mvp_staking_pool --- contracts/mvp_staking_pool/src/lib.rs | 135 ++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/contracts/mvp_staking_pool/src/lib.rs b/contracts/mvp_staking_pool/src/lib.rs index 460549a18..fba39bf3b 100644 --- a/contracts/mvp_staking_pool/src/lib.rs +++ b/contracts/mvp_staking_pool/src/lib.rs @@ -1187,6 +1187,141 @@ mod stake_partition { assert_eq!(client.used_stake(&user), 500i128); assert_eq!(client.staked_balance(&user), 500i128); } + + // ------------------------------------------------------------------------- + // Invariant: used + unused == total_staked across a multi-op sequence + // ------------------------------------------------------------------------- + #[test] + fn invariant_used_plus_unused_equals_total_staked_across_ops() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token) = setup(&env); + let user = Address::generate(&env); + StellarAssetClient::new(&env, &token).mint(&user, &1_000i128); + StellarAssetClient::new(&env, &token).mint(&admin, &1_000i128); + + // Stake 500, invariant holds + client.stake(&user, &500i128); + assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + + // Utilize 200, invariant still holds + client.utilize_stake(&admin, &user, &200i128).unwrap(); + assert_eq!(client.used_stake(&user), 200i128); + assert_eq!(client.unused_stake(&user), 300i128); + assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + + // Fund rewards and claim — does not change stake invariant + client.fund_rewards(&admin, &100i128); + let claimable = client.claimable(&user); + assert!(claimable > 0); + let claimed = client.claim(&user); + assert_eq!(claimed, claimable); + assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + + // Unstake 100 from unused portion, invariant holds + client.unstake(&user, &100i128).unwrap(); + assert_eq!(client.unused_stake(&user), 200i128); + assert_eq!(client.used_stake(&user), 200i128); + assert_eq!(client.staked_balance(&user), 400i128); + assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + } + + // ------------------------------------------------------------------------- + // Over-utilization is rejected + // ------------------------------------------------------------------------- + #[test] + fn utilize_stake_rejects_over_utilization() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token) = setup(&env); + let user = Address::generate(&env); + StellarAssetClient::new(&env, &token).mint(&user, &100i128); + client.stake(&user, &100i128); + + // Exact amount succeeds + client.utilize_stake(&admin, &user, &100i128).unwrap(); + // Any further utilization should fail + let err = client.try_utilize_stake(&admin, &user, &1i128).unwrap_err().unwrap(); + assert_eq!(err, ContractError::UtilizationExceedsUnused); + } + + #[test] + fn utilize_stake_rejects_exceeding_unused_after_partial_utilization() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token) = setup(&env); + let user = Address::generate(&env); + StellarAssetClient::new(&env, &token).mint(&user, &200i128); + client.stake(&user, &200i128); + // Utilize 150; only 50 unused left + client.utilize_stake(&admin, &user, &150i128).unwrap(); + // Try to utilize 100 more — should fail + let err = client.try_utilize_stake(&admin, &user, &100i128).unwrap_err().unwrap(); + assert_eq!(err, ContractError::UtilizationExceedsUnused); + } + + // ------------------------------------------------------------------------- + // Unstaking utilized stake is rejected + // ------------------------------------------------------------------------- + #[test] + fn unstake_rejects_withdrawal_of_utilized_stake() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token) = setup(&env); + let user = Address::generate(&env); + StellarAssetClient::new(&env, &token).mint(&user, &200i128); + client.stake(&user, &200i128); + // Utilize 150; only 50 unused + client.utilize_stake(&admin, &user, &150i128).unwrap(); + // Unstake 51 — exceeds unused (50) + let err = client.try_unstake(&user, &51i128).unwrap_err().unwrap(); + assert_eq!(err, ContractError::InsufficientUnusedStake); + } + + // ------------------------------------------------------------------------- + // Reward claim accounting is correct under partial utilization + // ------------------------------------------------------------------------- + #[test] + fn rewards_accrue_on_full_balance_even_when_partially_utilized() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token) = setup(&env); + let user = Address::generate(&env); + StellarAssetClient::new(&env, &token).mint(&user, &1_000i128); + StellarAssetClient::new(&env, &token).mint(&admin, &1_000i128); + + client.stake(&user, &1_000i128); + // Utilize half + client.utilize_stake(&admin, &user, &500i128).unwrap(); + // Fund 1000 tokens as rewards — user is the sole staker so gets all + client.fund_rewards(&admin, &1_000i128); + assert_eq!(client.claimable(&user), 1_000i128); + let claimed = client.claim(&user); + assert_eq!(claimed, 1_000i128); + assert_eq!(client.claimable(&user), 0i128); + + // After claim, stake invariant still holds + assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + } + + #[test] + fn reward_claim_zero_after_double_claim() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token) = setup(&env); + let user = Address::generate(&env); + StellarAssetClient::new(&env, &token).mint(&user, &500i128); + StellarAssetClient::new(&env, &token).mint(&admin, &500i128); + + client.stake(&user, &500i128); + client.utilize_stake(&admin, &user, &200i128).unwrap(); + client.fund_rewards(&admin, &500i128); + + let first = client.claim(&user); + assert_eq!(first, 500i128); + let second = client.claim(&user); + assert_eq!(second, 0i128); + } } // ============================================================================ From a675508ec6ffc20f73607b12b05ba08da9337404 Mon Sep 17 00:00:00 2001 From: 0xNinx Date: Fri, 31 Jul 2026 23:19:34 +0100 Subject: [PATCH 2/6] Fix formatting in mvp_staking_pool/src/lib.rs --- contracts/mvp_staking_pool/src/lib.rs | 35 +++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/contracts/mvp_staking_pool/src/lib.rs b/contracts/mvp_staking_pool/src/lib.rs index fba39bf3b..72e962edd 100644 --- a/contracts/mvp_staking_pool/src/lib.rs +++ b/contracts/mvp_staking_pool/src/lib.rs @@ -1202,13 +1202,19 @@ mod stake_partition { // Stake 500, invariant holds client.stake(&user, &500i128); - assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + assert_eq!( + client.used_stake(&user) + client.unused_stake(&user), + client.staked_balance(&user) + ); // Utilize 200, invariant still holds client.utilize_stake(&admin, &user, &200i128).unwrap(); assert_eq!(client.used_stake(&user), 200i128); assert_eq!(client.unused_stake(&user), 300i128); - assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + assert_eq!( + client.used_stake(&user) + client.unused_stake(&user), + client.staked_balance(&user) + ); // Fund rewards and claim — does not change stake invariant client.fund_rewards(&admin, &100i128); @@ -1216,14 +1222,20 @@ mod stake_partition { assert!(claimable > 0); let claimed = client.claim(&user); assert_eq!(claimed, claimable); - assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + assert_eq!( + client.used_stake(&user) + client.unused_stake(&user), + client.staked_balance(&user) + ); // Unstake 100 from unused portion, invariant holds client.unstake(&user, &100i128).unwrap(); assert_eq!(client.unused_stake(&user), 200i128); assert_eq!(client.used_stake(&user), 200i128); assert_eq!(client.staked_balance(&user), 400i128); - assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + assert_eq!( + client.used_stake(&user) + client.unused_stake(&user), + client.staked_balance(&user) + ); } // ------------------------------------------------------------------------- @@ -1241,7 +1253,10 @@ mod stake_partition { // Exact amount succeeds client.utilize_stake(&admin, &user, &100i128).unwrap(); // Any further utilization should fail - let err = client.try_utilize_stake(&admin, &user, &1i128).unwrap_err().unwrap(); + let err = client + .try_utilize_stake(&admin, &user, &1i128) + .unwrap_err() + .unwrap(); assert_eq!(err, ContractError::UtilizationExceedsUnused); } @@ -1256,7 +1271,10 @@ mod stake_partition { // Utilize 150; only 50 unused left client.utilize_stake(&admin, &user, &150i128).unwrap(); // Try to utilize 100 more — should fail - let err = client.try_utilize_stake(&admin, &user, &100i128).unwrap_err().unwrap(); + let err = client + .try_utilize_stake(&admin, &user, &100i128) + .unwrap_err() + .unwrap(); assert_eq!(err, ContractError::UtilizationExceedsUnused); } @@ -1301,7 +1319,10 @@ mod stake_partition { assert_eq!(client.claimable(&user), 0i128); // After claim, stake invariant still holds - assert_eq!(client.used_stake(&user) + client.unused_stake(&user), client.staked_balance(&user)); + assert_eq!( + client.used_stake(&user) + client.unused_stake(&user), + client.staked_balance(&user) + ); } #[test] From 3f4f5e009a10d6051aa5a415ee0c6ca75ff4982f Mon Sep 17 00:00:00 2001 From: 0xNinx Date: Sat, 1 Aug 2026 11:32:27 +0100 Subject: [PATCH 3/6] Add comprehensive tests for erasure routes (adminErasure, tenantErasure, userErasure) --- backend/src/routes/adminErasure.test.ts | 150 +++++++++++++ backend/src/routes/tenantErasure.test.ts | 171 +++++++++++++++ backend/src/routes/userErasure.test.ts | 260 +++++++++++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 backend/src/routes/adminErasure.test.ts create mode 100644 backend/src/routes/tenantErasure.test.ts create mode 100644 backend/src/routes/userErasure.test.ts diff --git a/backend/src/routes/adminErasure.test.ts b/backend/src/routes/adminErasure.test.ts new file mode 100644 index 000000000..dd5ac4eee --- /dev/null +++ b/backend/src/routes/adminErasure.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import request from 'supertest' +import express from 'express' +import { errorHandler } from '../middleware/errorHandler.js' +import { createAdminErasureRouter } from './adminErasure.js' + +const { mockConfirmErasure } = vi.hoisted(() => ({ + mockConfirmErasure: vi.fn(), +})) + +vi.mock('../services/erasureService.js', () => ({ + erasureService: { + confirmErasure: mockConfirmErasure, + }, +})) + +vi.mock('../utils/auditLogger.js', () => ({ + auditLog: vi.fn(), + extractAuditContext: vi.fn(() => ({})), +})) + +vi.mock('../schemas/env.js', () => ({ + env: { + MANUAL_ADMIN_SECRET: 'test-secret', + }, +})) + +vi.mock('../db.js', () => ({ + getPool: vi.fn(async () => null), + setPool: vi.fn(), + getPoolMetrics: vi.fn(() => null), +})) + +function buildApp() { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + next() + }) + app.use('/api/admin/erasure', createAdminErasureRouter()) + app.use(errorHandler) + return app +} + +describe('Admin Erasure Routes', () => { + beforeEach(() => { + mockConfirmErasure.mockReset() + }) + + describe('POST /api/admin/erasure/:requestId/confirm', () => { + const validRequestId = '550e8400-e29b-41d4-a716-446655440000' + + it('should confirm erasure with valid admin secret', async () => { + mockConfirmErasure.mockResolvedValue(undefined) + + const res = await request(buildApp()) + .post(`/api/admin/erasure/${validRequestId}/confirm`) + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + expect(res.body.message).toBe('User data anonymised and account deactivated') + expect(mockConfirmErasure).toHaveBeenCalledWith(validRequestId, 'admin') + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()) + .post(`/api/admin/erasure/${validRequestId}/confirm`) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + expect(res.body.error.message).toBe('Invalid admin secret') + }) + + it('should reject request with invalid admin secret', async () => { + const res = await request(buildApp()) + .post(`/api/admin/erasure/${validRequestId}/confirm`) + .set('x-admin-secret', 'wrong-secret') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + expect(res.body.error.message).toBe('Invalid admin secret') + }) + + it('should return 404 when erasure request not found', async () => { + mockConfirmErasure.mockRejectedValue(new Error('ERASURE_NOT_FOUND')) + + const res = await request(buildApp()) + .post(`/api/admin/erasure/${validRequestId}/confirm`) + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + expect(res.body.error.message).toBe('Erasure request not found') + }) + + it('should return 409 when erasure request is not pending', async () => { + mockConfirmErasure.mockRejectedValue(new Error('ERASURE_NOT_PENDING')) + + const res = await request(buildApp()) + .post(`/api/admin/erasure/${validRequestId}/confirm`) + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(409) + expect(res.body.error.code).toBe('CONFLICT') + expect(res.body.error.message).toBe('Erasure request is not pending') + }) + + it('should return 400 for invalid UUID in requestId', async () => { + const res = await request(buildApp()) + .post('/api/admin/erasure/invalid-uuid/confirm') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should use authenticated user id when available', async () => { + mockConfirmErasure.mockResolvedValue(undefined) + + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-user-123' } + next() + }) + app.use('/api/admin/erasure', createAdminErasureRouter()) + app.use(errorHandler) + + const res = await request(app) + .post(`/api/admin/erasure/${validRequestId}/confirm`) + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(mockConfirmErasure).toHaveBeenCalledWith(validRequestId, 'admin-user-123') + }) + + it('should propagate unexpected errors', async () => { + mockConfirmErasure.mockRejectedValue(new Error('Database connection failed')) + + const res = await request(buildApp()) + .post(`/api/admin/erasure/${validRequestId}/confirm`) + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(500) + }) + }) +}) diff --git a/backend/src/routes/tenantErasure.test.ts b/backend/src/routes/tenantErasure.test.ts new file mode 100644 index 000000000..250562ef8 --- /dev/null +++ b/backend/src/routes/tenantErasure.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import request from 'supertest' +import express from 'express' +import { errorHandler } from '../middleware/errorHandler.js' +import { createTenantErasureRouter } from './tenantErasure.js' + +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + }, +})) + +vi.mock('../middleware/auth.js', () => ({ + authenticateToken: (_req: any, _res: any, next: any) => next(), + type: {}, +})) + +vi.mock('../db.js', () => ({ + getPool: vi.fn(async () => null), + setPool: vi.fn(), + getPoolMetrics: vi.fn(() => null), +})) + +function buildApp() { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + next() + }) + app.use('/api/tenant/erasure', createTenantErasureRouter()) + app.use(errorHandler) + return app +} + +describe('Tenant Erasure Routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('POST /api/tenant/erasure/request', () => { + it('should create erasure request for authenticated tenant', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-123' } + next() + }) + app.use('/api/tenant/erasure', createTenantErasureRouter()) + app.use(errorHandler) + + const res = await request(app).post('/api/tenant/erasure/request') + + expect(res.status).toBe(202) + expect(res.body.requestId).toBeDefined() + expect(typeof res.body.requestId).toBe('string') + expect(res.body.message).toContain('Right-to-Erasure request has been received') + expect(res.body.confirmBy).toBeDefined() + expect(res.body.confirmBy).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + }) + + it.skip('should reject unauthenticated request', async () => { + // Authentication is tested in auth.test.ts + // This route uses authenticateToken middleware which returns 401 for unauthenticated requests + }) + + it.skip('should return 409 when tenant has active deal', async () => { + // Note: The hasActiveDeal function is currently a stub that always returns false. + // This test documents the expected behavior when the function is implemented. + // To properly test this, the route would need to be refactored to make hasActiveDeal + // injectable or testable via dependency injection. + // Expected behavior: + // expect(res.status).toBe(409) + // expect(res.body.error.code).toBe('CONFLICT') + // expect(res.body.error.message).toContain('active rental deal') + }) + + it.skip('should return 401 when user id is missing from request', async () => { + // This is covered by the route's own check: if (!userId) throw AppError(UNAUTHORIZED) + // Authentication middleware is tested in auth.test.ts + }) + + it('should generate unique request IDs for multiple requests', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-456' } + next() + }) + app.use('/api/tenant/erasure', createTenantErasureRouter()) + app.use(errorHandler) + + const res1 = await request(app).post('/api/tenant/erasure/request') + const res2 = await request(app).post('/api/tenant/erasure/request') + + expect(res1.status).toBe(202) + expect(res2.status).toBe(202) + expect(res1.body.requestId).toBeDefined() + expect(res2.body.requestId).toBeDefined() + expect(res1.body.requestId).not.toBe(res2.body.requestId) + }) + + it('should set confirmBy date to 30 days from now', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-789' } + next() + }) + app.use('/api/tenant/erasure', createTenantErasureRouter()) + app.use(errorHandler) + + const beforeRequest = new Date() + const res = await request(app).post('/api/tenant/erasure/request') + const afterRequest = new Date() + + expect(res.status).toBe(202) + const confirmBy = new Date(res.body.confirmBy) + const expectedMin = new Date(beforeRequest.getTime() + 30 * 24 * 60 * 60 * 1000) + const expectedMax = new Date(afterRequest.getTime() + 30 * 24 * 60 * 60 * 1000) + + expect(confirmBy.getTime()).toBeGreaterThanOrEqual(expectedMin.getTime() - 1000) + expect(confirmBy.getTime()).toBeLessThanOrEqual(expectedMax.getTime() + 1000) + }) + + it('should log erasure request creation', async () => { + const { logger } = await import('../utils/logger.js') + + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-log-test' } + next() + }) + app.use('/api/tenant/erasure', createTenantErasureRouter()) + app.use(errorHandler) + + await request(app).post('/api/tenant/erasure/request') + + expect(logger.info).toHaveBeenCalledWith( + 'tenantErasure.requested', + expect.objectContaining({ + requestId: expect.any(String), + userId: 'tenant-log-test', + confirmBy: expect.any(String), + }) + ) + }) + + it('should handle unexpected errors gracefully', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-error' } + // Simulate an error by throwing in the middleware + throw new Error('Unexpected error') + }) + app.use('/api/tenant/erasure', createTenantErasureRouter()) + app.use(errorHandler) + + const res = await request(app).post('/api/tenant/erasure/request') + + expect(res.status).toBe(500) + }) + }) +}) diff --git a/backend/src/routes/userErasure.test.ts b/backend/src/routes/userErasure.test.ts new file mode 100644 index 000000000..90788c882 --- /dev/null +++ b/backend/src/routes/userErasure.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import request from 'supertest' +import express from 'express' +import { errorHandler } from '../middleware/errorHandler.js' +import { createUserErasureRouter } from './userErasure.js' + +const { mockRequestErasure, mockJobCreate } = vi.hoisted(() => ({ + mockRequestErasure: vi.fn(), + mockJobCreate: vi.fn(), +})) + +vi.mock('../services/erasureService.js', () => ({ + erasureService: { + requestErasure: mockRequestErasure, + }, +})) + +vi.mock('../jobs/scheduler/store.js', () => ({ + getJobStore: vi.fn(() => ({ + create: mockJobCreate, + })), +})) + +vi.mock('../utils/auditLogger.js', () => ({ + auditLog: vi.fn(), + extractAuditContext: vi.fn(() => ({})), +})) + +vi.mock('../middleware/auth.js', () => ({ + authenticateToken: (_req: any, _res: any, next: any) => next(), + type: {}, +})) + +vi.mock('../db.js', () => ({ + getPool: vi.fn(async () => null), + setPool: vi.fn(), + getPoolMetrics: vi.fn(() => null), +})) + +function buildApp() { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + next() + }) + app.use('/api/user', createUserErasureRouter()) + app.use(errorHandler) + return app +} + +describe('User Erasure Routes', () => { + beforeEach(() => { + mockRequestErasure.mockReset() + mockJobCreate.mockReset() + }) + + describe('POST /api/user/request-erasure', () => { + it('should create erasure request for authenticated user', async () => { + const mockRequest = { + id: '550e8400-e29b-41d4-a716-446655440000', + userId: 'user-123', + status: 'pending' as const, + requestedAt: new Date(), + confirmBy: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + confirmedAt: null, + confirmedBy: null, + } + + mockRequestErasure.mockResolvedValue(mockRequest) + mockJobCreate.mockResolvedValue({ + id: 'job-123', + name: 'ERASURE_REQUESTED', + handler: 'erasure.requested', + payload: { userId: 'user-123', requestId: mockRequest.id }, + }) + + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'user-123' } + next() + }) + app.use('/api/user', createUserErasureRouter()) + app.use(errorHandler) + + const res = await request(app).post('/api/user/request-erasure') + + expect(res.status).toBe(202) + expect(res.body.message).toBe('Erasure request submitted. An administrator will confirm within 30 days.') + expect(res.body.requestId).toBe(mockRequest.id) + expect(res.body.confirmBy).toBeDefined() + expect(res.body.confirmBy).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + expect(mockRequestErasure).toHaveBeenCalledWith('user-123') + expect(mockJobCreate).toHaveBeenCalledWith({ + name: 'ERASURE_REQUESTED', + handler: 'erasure.requested', + payload: { userId: 'user-123', requestId: mockRequest.id }, + priority: 3, + maxRetries: 3, + }) + }) + + it.skip('should reject unauthenticated request', async () => { + // Authentication is tested in auth.test.ts + // This route uses authenticateToken middleware which returns 401 for unauthenticated requests + }) + + it('should return 409 when erasure request already pending', async () => { + mockRequestErasure.mockRejectedValue(new Error('ERASURE_ALREADY_PENDING')) + + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'user-456' } + next() + }) + app.use('/api/user', createUserErasureRouter()) + app.use(errorHandler) + + const res = await request(app).post('/api/user/request-erasure') + + expect(res.status).toBe(409) + expect(res.body.error.code).toBe('CONFLICT') + expect(res.body.error.message).toBe('An erasure request is already pending') + }) + + it.skip('should return 401 when user id is missing from request', async () => { + // This is covered by the route's own check: if (!userId) throw AppError(UNAUTHORIZED) + // Authentication middleware is tested in auth.test.ts + }) + + it('should propagate unexpected errors from erasureService', async () => { + mockRequestErasure.mockRejectedValue(new Error('Database connection failed')) + + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'user-789' } + next() + }) + app.use('/api/user', createUserErasureRouter()) + app.use(errorHandler) + + const res = await request(app).post('/api/user/request-erasure') + + expect(res.status).toBe(500) + }) + + it('should propagate unexpected errors from job creation', async () => { + const mockRequest = { + id: '550e8400-e29b-41d4-a716-446655440000', + userId: 'user-999', + status: 'pending' as const, + requestedAt: new Date(), + confirmBy: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + confirmedAt: null, + confirmedBy: null, + } + + mockRequestErasure.mockResolvedValue(mockRequest) + mockJobCreate.mockRejectedValue(new Error('Job store unavailable')) + + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'user-999' } + next() + }) + app.use('/api/user', createUserErasureRouter()) + app.use(errorHandler) + + const res = await request(app).post('/api/user/request-erasure') + + expect(res.status).toBe(500) + }) + + it('should log audit event on successful request', async () => { + const mockRequest = { + id: '550e8400-e29b-41d4-a716-446655440000', + userId: 'user-audit', + status: 'pending' as const, + requestedAt: new Date(), + confirmBy: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + confirmedAt: null, + confirmedBy: null, + } + + mockRequestErasure.mockResolvedValue(mockRequest) + mockJobCreate.mockResolvedValue({ + id: 'job-audit', + name: 'ERASURE_REQUESTED', + handler: 'erasure.requested', + payload: { userId: 'user-audit', requestId: mockRequest.id }, + }) + + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'user-audit' } + next() + }) + app.use('/api/user', createUserErasureRouter()) + app.use(errorHandler) + + await request(app).post('/api/user/request-erasure') + + const { auditLog } = await import('../utils/auditLogger.js') + expect(auditLog).toHaveBeenCalledWith( + 'USER_ERASURE_REQUESTED', + expect.any(Object), + expect.objectContaining({ + requestId: mockRequest.id, + confirmBy: expect.any(String), + }) + ) + }) + + it('should return confirmBy date matching service response', async () => { + const futureDate = new Date('2026-12-31T23:59:59.999Z') + const mockRequest = { + id: '550e8400-e29b-41d4-a716-446655440000', + userId: 'user-date', + status: 'pending' as const, + requestedAt: new Date(), + confirmBy: futureDate, + confirmedAt: null, + confirmedBy: null, + } + + mockRequestErasure.mockResolvedValue(mockRequest) + mockJobCreate.mockResolvedValue({ + id: 'job-date', + name: 'ERASURE_REQUESTED', + handler: 'erasure.requested', + payload: { userId: 'user-date', requestId: mockRequest.id }, + }) + + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'user-date' } + next() + }) + app.use('/api/user', createUserErasureRouter()) + app.use(errorHandler) + + const res = await request(app).post('/api/user/request-erasure') + + expect(res.status).toBe(202) + expect(res.body.confirmBy).toBe(futureDate.toISOString()) + }) + }) +}) From 7adad7bbd2d5a4fa18d8042448cf210745506791 Mon Sep 17 00:00:00 2001 From: 0xNinx Date: Sat, 1 Aug 2026 11:53:28 +0100 Subject: [PATCH 4/6] Add comprehensive tests for abuse, adminFraud, and adminQuota routes - abuse.test.ts: 10 tests for abuse event retrieval with pagination and authorization - adminFraud.test.ts: 40 tests for fraud signal, assessment, hold, and threshold management - adminQuota.test.ts: 23 tests for quota usage, overrides, stats, and reset operations All tests follow established conventions covering success paths, authorization, validation failures, not-found/conflict paths, and response shape assertions. --- backend/src/routes/abuse.test.ts | 258 +++++++++++ backend/src/routes/adminFraud.test.ts | 640 ++++++++++++++++++++++++++ backend/src/routes/adminQuota.test.ts | 592 ++++++++++++++++++++++++ 3 files changed, 1490 insertions(+) create mode 100644 backend/src/routes/abuse.test.ts create mode 100644 backend/src/routes/adminFraud.test.ts create mode 100644 backend/src/routes/adminQuota.test.ts diff --git a/backend/src/routes/abuse.test.ts b/backend/src/routes/abuse.test.ts new file mode 100644 index 000000000..3d64dcad3 --- /dev/null +++ b/backend/src/routes/abuse.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import request from 'supertest' +import express from 'express' +import { errorHandler } from '../middleware/errorHandler.js' +import { createAbuseRouter } from './abuse.js' +import { abuseEventStore } from '../services/abuseDetectionService.js' + +vi.mock('../middleware/auth.js', () => ({ + authenticateToken: (_req: any, _res: any, next: any) => next(), + type: {}, +})) + +vi.mock('../db.js', () => ({ + getPool: vi.fn(async () => null), + setPool: vi.fn(), + getPoolMetrics: vi.fn(() => null), +})) + +function buildApp() { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + return app +} + +describe('Abuse Routes', () => { + beforeEach(() => { + abuseEventStore.clear() + }) + + describe('GET /api/admin/abuse/events', () => { + it('should return paginated abuse events for admin user', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', role: 'admin' } + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + // Add some test events + abuseEventStore.add({ + target: '192.168.1.1', + type: 'credential_stuffing', + expiresAt: new Date(Date.now() + 3600000), + }) + abuseEventStore.add({ + target: 'user-456', + type: 'deal_spam', + expiresAt: new Date(Date.now() + 3600000), + }) + + const res = await request(app).get('/api/admin/abuse/events?page=1&pageSize=10') + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + expect(res.body.events).toBeInstanceOf(Array) + expect(res.body.events.length).toBe(2) + expect(res.body.pagination).toBeDefined() + expect(res.body.pagination.total).toBe(2) + expect(res.body.pagination.page).toBe(1) + expect(res.body.pagination.pageSize).toBe(10) + expect(res.body.pagination.totalPages).toBe(1) + }) + + it('should reject request from non-admin user', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-123', role: 'tenant' } + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/abuse/events') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + expect(res.body.error.message).toBe('Admin role required') + }) + + it('should reject request from unauthenticated user', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + // No user object + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/abuse/events') + + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') + expect(res.body.error.message).toBe('Authentication required') + }) + + it('should allow super_admin role', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'super-admin-123', role: 'super_admin' } + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/abuse/events') + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + }) + + it('should return empty events array when no events exist', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', role: 'admin' } + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/abuse/events') + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + expect(res.body.events).toEqual([]) + expect(res.body.pagination.total).toBe(0) + }) + + it('should handle pagination with default values', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', role: 'admin' } + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/abuse/events') + + expect(res.status).toBe(200) + expect(res.body.pagination.page).toBe(1) + expect(res.body.pagination.pageSize).toBe(20) + }) + + it('should validate page parameter - minimum 1', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', role: 'admin' } + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/abuse/events?page=0') + + expect(res.status).toBe(200) + expect(res.body.pagination.page).toBe(1) // Clamped to minimum 1 + }) + + it('should validate pageSize parameter - maximum 100', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', role: 'admin' } + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/abuse/events?pageSize=200') + + expect(res.status).toBe(200) + expect(res.body.pagination.pageSize).toBe(100) // Clamped to maximum 100 + }) + + it('should return event with correct structure', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', role: 'admin' } + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + const testEvent = abuseEventStore.add({ + target: '192.168.1.100', + type: 'scraping', + expiresAt: new Date(Date.now() + 7200000), + }) + + const res = await request(app).get('/api/admin/abuse/events') + + expect(res.status).toBe(200) + expect(res.body.events[0]).toMatchObject({ + id: testEvent.id, + target: '192.168.1.100', + type: 'scraping', + }) + expect(res.body.events[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + expect(res.body.events[0].expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + }) + + it('should exclude expired events from results', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', role: 'admin' } + next() + }) + app.use('/api/admin/abuse', createAbuseRouter()) + app.use(errorHandler) + + // Add expired event + abuseEventStore.add({ + target: '192.168.1.200', + type: 'credential_stuffing', + expiresAt: new Date(Date.now() - 1000), // Expired + }) + + // Add active event + abuseEventStore.add({ + target: '192.168.1.201', + type: 'credential_stuffing', + expiresAt: new Date(Date.now() + 3600000), + }) + + const res = await request(app).get('/api/admin/abuse/events') + + expect(res.status).toBe(200) + expect(res.body.events.length).toBe(1) + expect(res.body.events[0].target).toBe('192.168.1.201') + }) + }) +}) diff --git a/backend/src/routes/adminFraud.test.ts b/backend/src/routes/adminFraud.test.ts new file mode 100644 index 000000000..4506aa82c --- /dev/null +++ b/backend/src/routes/adminFraud.test.ts @@ -0,0 +1,640 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import request from 'supertest' +import express from 'express' +import { errorHandler } from '../middleware/errorHandler.js' +import { createAdminFraudRouter } from './adminFraud.js' +import { getFraudStore } from '../fraud/store.js' +import { getFraudEngine } from '../fraud/engine.js' +import { SignalType, RiskLevel, EntityType, ActionType } from '../fraud/types.js' + +vi.mock('../fraud/store.js', () => ({ + getFraudStore: vi.fn(), + InMemoryFraudStore: vi.fn().mockImplementation(() => ({ + createSignal: vi.fn(), + getSignal: vi.fn(), + listSignals: vi.fn(), + updateSignal: vi.fn(), + deleteSignal: vi.fn(), + enableSignal: vi.fn(), + disableSignal: vi.fn(), + createAssessment: vi.fn(), + getAssessment: vi.fn(), + getAssessmentsByEntity: vi.fn(), + listAssessments: vi.fn(), + createAccountHold: vi.fn(), + getActiveHolds: vi.fn(), + releaseHold: vi.fn(), + })), +})) + +vi.mock('../fraud/engine.js', () => ({ + getFraudEngine: vi.fn(), + FraudDetectionEngine: vi.fn().mockImplementation(() => ({ + evaluate: vi.fn(), + updateThresholds: vi.fn(), + getThresholds: vi.fn(), + })), +})) + +vi.mock('../schemas/env.js', () => ({ + env: { + MANUAL_ADMIN_SECRET: 'test-secret', + }, +})) + +vi.mock('../db.js', () => ({ + getPool: vi.fn(async () => null), + setPool: vi.fn(), + getPoolMetrics: vi.fn(() => null), +})) + +function buildApp() { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + next() + }) + app.use('/api/admin/fraud', createAdminFraudRouter()) + app.use(errorHandler) + return app +} + +describe('Admin Fraud Routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Signal Management', () => { + describe('GET /api/admin/fraud/signals', () => { + it('should list all signals with valid admin secret', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + listSignals: vi.fn().mockResolvedValue([ + { + id: 'signal-1', + name: 'Test Signal', + signalType: SignalType.THRESHOLD, + enabled: true, + }, + ]), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/signals') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.signals).toBeInstanceOf(Array) + expect(res.body.signals.length).toBeGreaterThan(0) + expect(res.body.signals[0]).toMatchObject({ + name: 'Test Signal', + signalType: SignalType.THRESHOLD, + enabled: true, + }) + }) + + it('should filter signals by enabled status', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + listSignals: vi.fn().mockResolvedValue([ + { id: '1', name: 'Enabled Signal', signalType: SignalType.THRESHOLD, enabled: true }, + ]), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/signals?enabled=true') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.signals.every((s: any) => s.enabled === true)).toBe(true) + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/fraud/signals') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + expect(res.body.error.message).toBe('Invalid admin secret') + }) + + it('should reject request with invalid admin secret', async () => { + const res = await request(buildApp()) + .get('/api/admin/fraud/signals') + .set('x-admin-secret', 'wrong-secret') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + expect(res.body.error.message).toBe('Invalid admin secret') + }) + }) + + describe('GET /api/admin/fraud/signals/:id', () => { + it('should get a single signal by id', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + getSignal: vi.fn().mockResolvedValue({ + id: 'signal-1', + name: 'Test Signal', + signalType: SignalType.RULE, + }), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/signals/signal-1') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.signal.id).toBe('signal-1') + expect(res.body.signal.name).toBe('Test Signal') + }) + + it('should return 404 for non-existent signal', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + getSignal: vi.fn().mockResolvedValue(null), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/signals/non-existent-id') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + expect(res.body.error.message).toContain('not found') + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/fraud/signals/some-id') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('POST /api/admin/fraud/signals', () => { + it('should create a new signal', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + createSignal: vi.fn().mockResolvedValue({ + id: 'signal-1', + name: 'New Fraud Signal', + signalType: SignalType.PATTERN, + }), + } as any) + + const newSignal = { + name: 'New Fraud Signal', + description: 'Test description', + signalType: SignalType.PATTERN, + config: { pattern: '^\\d+$' }, + enabled: true, + scoreWeight: 25, + } + + const res = await request(buildApp()) + .post('/api/admin/fraud/signals') + .set('x-admin-secret', 'test-secret') + .send(newSignal) + + expect(res.status).toBe(201) + expect(res.body.signal.name).toBe('New Fraud Signal') + expect(res.body.signal.signalType).toBe(SignalType.PATTERN) + expect(res.body.signal.id).toBeDefined() + }) + + it('should validate required fields', async () => { + const res = await request(buildApp()) + .post('/api/admin/fraud/signals') + .set('x-admin-secret', 'test-secret') + .send({ name: '' }) // Invalid: empty name + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should validate scoreWeight range', async () => { + const res = await request(buildApp()) + .post('/api/admin/fraud/signals') + .set('x-admin-secret', 'test-secret') + .send({ + name: 'Test', + signalType: SignalType.THRESHOLD, + config: {}, + scoreWeight: 150, // Invalid: > 100 + }) + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()) + .post('/api/admin/fraud/signals') + .send({ name: 'Test', signalType: SignalType.THRESHOLD, config: {} }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('PUT /api/admin/fraud/signals/:id', () => { + it('should update an existing signal', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + updateSignal: vi.fn().mockResolvedValue({ + id: 'signal-1', + name: 'Updated Name', + }), + } as any) + + const res = await request(buildApp()) + .put('/api/admin/fraud/signals/signal-1') + .set('x-admin-secret', 'test-secret') + .send({ name: 'Updated Name' }) + + expect(res.status).toBe(200) + expect(res.body.signal.name).toBe('Updated Name') + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()) + .put('/api/admin/fraud/signals/some-id') + .send({ name: 'Updated' }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('DELETE /api/admin/fraud/signals/:id', () => { + it('should delete a signal', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + deleteSignal: vi.fn().mockResolvedValue(undefined), + } as any) + + const res = await request(buildApp()) + .delete('/api/admin/fraud/signals/signal-1') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).delete('/api/admin/fraud/signals/some-id') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('POST /api/admin/fraud/signals/:id/enable', () => { + it('should enable a signal', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + enableSignal: vi.fn().mockResolvedValue(undefined), + } as any) + + const res = await request(buildApp()) + .post('/api/admin/fraud/signals/signal-1/enable') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).post('/api/admin/fraud/signals/some-id/enable') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('POST /api/admin/fraud/signals/:id/disable', () => { + it('should disable a signal', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + disableSignal: vi.fn().mockResolvedValue(undefined), + } as any) + + const res = await request(buildApp()) + .post('/api/admin/fraud/signals/signal-1/disable') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).post('/api/admin/fraud/signals/some-id/disable') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + }) + + describe('Assessment Management', () => { + describe('POST /api/admin/fraud/evaluate', () => { + it('should evaluate an event against fraud signals', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + createAssessment: vi.fn().mockResolvedValue({ + id: 'assessment-1', + entityId: 'account-123', + riskLevel: RiskLevel.LOW, + }), + } as any) + vi.mocked(getFraudEngine).mockReturnValue({ + evaluate: vi.fn().mockResolvedValue({ + id: 'assessment-1', + entityId: 'account-123', + riskLevel: RiskLevel.LOW, + }), + } as any) + + const evaluationRequest = { + entityType: EntityType.ACCOUNT, + entityId: 'account-123', + eventData: { amount: 5000 }, + metadata: { source: 'manual' }, + } + + const res = await request(buildApp()) + .post('/api/admin/fraud/evaluate') + .set('x-admin-secret', 'test-secret') + .send(evaluationRequest) + + expect(res.status).toBe(200) + expect(res.body.assessment).toBeDefined() + expect(res.body.assessment.entityId).toBe('account-123') + expect(res.body.assessment.riskLevel).toBeDefined() + }) + + it('should validate required fields', async () => { + const res = await request(buildApp()) + .post('/api/admin/fraud/evaluate') + .set('x-admin-secret', 'test-secret') + .send({ entityType: EntityType.ACCOUNT }) // Missing entityId + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()) + .post('/api/admin/fraud/evaluate') + .send({ + entityType: EntityType.ACCOUNT, + entityId: 'account-123', + eventData: {}, + }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('GET /api/admin/fraud/assessments', () => { + it('should list assessments with filters', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + listAssessments: vi.fn().mockResolvedValue([ + { + id: 'assessment-1', + entityId: 'account-123', + riskLevel: RiskLevel.HIGH, + }, + ]), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/assessments?riskLevel=high&limit=10') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.assessments).toBeInstanceOf(Array) + }) + + it('should validate limit parameter', async () => { + const res = await request(buildApp()) + .get('/api/admin/fraud/assessments?limit=300') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/fraud/assessments') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('GET /api/admin/fraud/assessments/:id', () => { + it('should get a single assessment', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + getAssessment: vi.fn().mockResolvedValue({ + id: 'assessment-1', + entityId: 'payment-123', + riskLevel: RiskLevel.MEDIUM, + }), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/assessments/assessment-1') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.assessment.id).toBe('assessment-1') + }) + + it('should return 404 for non-existent assessment', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + getAssessment: vi.fn().mockResolvedValue(null), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/assessments/non-existent') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/fraud/assessments/some-id') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('GET /api/admin/fraud/assessments/entity/:type/:id', () => { + it('should get assessments for a specific entity', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + getAssessmentsByEntity: vi.fn().mockResolvedValue([ + { + id: 'assessment-1', + entityId: 'account-456', + riskLevel: RiskLevel.MEDIUM, + }, + ]), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/assessments/entity/account/account-456') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.assessments).toBeInstanceOf(Array) + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()) + .get('/api/admin/fraud/assessments/entity/account/some-id') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + }) + + describe('Account Hold Management', () => { + describe('GET /api/admin/fraud/holds/:accountId', () => { + it('should get active holds for an account', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + getActiveHolds: vi.fn().mockResolvedValue([ + { + id: 'hold-1', + accountId: 'account-789', + holdType: 'full', + }, + ]), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/holds/account-789') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.holds).toBeInstanceOf(Array) + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/fraud/holds/some-account') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('POST /api/admin/fraud/holds/:holdId/release', () => { + it('should release an account hold', async () => { + vi.mocked(getFraudStore).mockReturnValue({ + releaseHold: vi.fn().mockResolvedValue(undefined), + } as any) + + const res = await request(buildApp()) + .post('/api/admin/fraud/holds/hold-1/release') + .set('x-admin-secret', 'test-secret') + .send({ releasedBy: 'admin-123' }) + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + }) + + it('should validate releasedBy field', async () => { + const res = await request(buildApp()) + .post('/api/admin/fraud/holds/some-hold-id/release') + .set('x-admin-secret', 'test-secret') + .send({}) // Missing releasedBy + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()) + .post('/api/admin/fraud/holds/some-hold-id/release') + .send({ releasedBy: 'admin-123' }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + }) + + describe('Threshold Management', () => { + describe('GET /api/admin/fraud/thresholds', () => { + it('should get current risk thresholds', async () => { + vi.mocked(getFraudEngine).mockReturnValue({ + getThresholds: vi.fn().mockReturnValue({ + medium: 30, + high: 60, + critical: 90, + }), + } as any) + + const res = await request(buildApp()) + .get('/api/admin/fraud/thresholds') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.thresholds).toBeDefined() + expect(res.body.thresholds.medium).toBeDefined() + expect(res.body.thresholds.high).toBeDefined() + expect(res.body.thresholds.critical).toBeDefined() + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/fraud/thresholds') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('PUT /api/admin/fraud/thresholds', () => { + it('should update risk thresholds', async () => { + vi.mocked(getFraudEngine).mockReturnValue({ + updateThresholds: vi.fn(), + getThresholds: vi.fn().mockReturnValue({ + medium: 35, + high: 65, + critical: 95, + }), + } as any) + + const newThresholds = { + medium: 35, + high: 65, + critical: 95, + } + + const res = await request(buildApp()) + .put('/api/admin/fraud/thresholds') + .set('x-admin-secret', 'test-secret') + .send(newThresholds) + + expect(res.status).toBe(200) + expect(res.body.thresholds.medium).toBe(35) + expect(res.body.thresholds.high).toBe(65) + expect(res.body.thresholds.critical).toBe(95) + }) + + it('should validate threshold values are non-negative', async () => { + const res = await request(buildApp()) + .put('/api/admin/fraud/thresholds') + .set('x-admin-secret', 'test-secret') + .send({ medium: -10 }) // Invalid: negative value + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should reject request without admin secret', async () => { + const res = await request(buildApp()) + .put('/api/admin/fraud/thresholds') + .send({ medium: 30 }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + }) +}) diff --git a/backend/src/routes/adminQuota.test.ts b/backend/src/routes/adminQuota.test.ts new file mode 100644 index 000000000..249590a47 --- /dev/null +++ b/backend/src/routes/adminQuota.test.ts @@ -0,0 +1,592 @@ +import { describe, it, expect, vi } from 'vitest' +import request from 'supertest' +import express from 'express' +import { errorHandler } from '../middleware/errorHandler.js' +import { createAdminQuotaRouter } from './adminQuota.js' +import { quotaManager } from '../services/QuotaManager.js' +import { burstRateLimiter } from '../services/BurstRateLimiter.js' + +vi.mock('../services/QuotaManager.js', () => ({ + quotaManager: { + getQuotaUsage: vi.fn(), + getUserOverrides: vi.fn(), + setOverride: vi.fn(), + removeOverride: vi.fn(), + getQuotaStats: vi.fn(), + }, +})) + +vi.mock('../services/BurstRateLimiter.js', () => ({ + burstRateLimiter: { + resetQuota: vi.fn(), + }, +})) + +vi.mock('../utils/logger.js', () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + }, +})) + +vi.mock('../middleware/auth.js', () => ({ + authenticateToken: (_req: any, _res: any, next: any) => next(), + type: {}, +})) + +vi.mock('../db.js', () => ({ + getPool: vi.fn(async () => null), + setPool: vi.fn(), + getPoolMetrics: vi.fn(() => null), +})) + +function buildApp() { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + return app +} + +describe('Admin Quota Routes', () => { + describe('GET /api/admin/quota/usage/:userId', () => { + it('should get quota usage for a user as admin', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(quotaManager.getQuotaUsage).mockResolvedValue({ + userId: 'user-456', + endpoint: 'all', + minuteUsage: 15, + dayUsage: 200, + minuteLimit: 100, + dayLimit: 1000, + minuteReset: Date.now() + 60000, + dayReset: Date.now() + 86400000, + nearLimit: false, + }) + + const res = await request(app).get('/api/admin/quota/usage/user-456') + + expect(res.status).toBe(200) + expect(res.body.userId).toBe('user-456') + expect(res.body.minuteUsage).toBe(15) + expect(res.body.dayUsage).toBe(200) + }) + + it('should reject request from non-admin user', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-123', isAdmin: false } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/quota/usage/user-456') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + expect(res.body.error.message).toBe('Admin access required') + }) + + it('should allow user with admin role', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', role: 'admin' } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(quotaManager.getQuotaUsage).mockResolvedValue({ + userId: 'user-789', + endpoint: 'all', + minuteUsage: 5, + dayUsage: 50, + minuteLimit: 100, + dayLimit: 1000, + minuteReset: Date.now() + 60000, + dayReset: Date.now() + 86400000, + nearLimit: false, + }) + + const res = await request(app).get('/api/admin/quota/usage/user-789') + + expect(res.status).toBe(200) + }) + + it('should support endpoint query parameter', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(quotaManager.getQuotaUsage).mockResolvedValue({ + userId: 'user-999', + endpoint: '/api/deals', + minuteUsage: 25, + dayUsage: 300, + minuteLimit: 100, + dayLimit: 1000, + minuteReset: Date.now() + 60000, + dayReset: Date.now() + 86400000, + nearLimit: true, + }) + + const res = await request(app).get('/api/admin/quota/usage/user-999?endpoint=/api/deals') + + expect(res.status).toBe(200) + expect(vi.mocked(quotaManager.getQuotaUsage)).toHaveBeenCalledWith('user-999', '/api/deals') + }) + }) + + describe('GET /api/admin/quota/overrides/:userId', () => { + it('should get quota overrides for a user as admin', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(quotaManager.getUserOverrides).mockResolvedValue([ + { + userId: 'user-456', + endpoint: '/api/payments', + elevatedLimit: 500, + reason: 'Business need', + createdBy: 'admin-123', + createdAt: Date.now(), + }, + ]) + + const res = await request(app).get('/api/admin/quota/overrides/user-456') + + expect(res.status).toBe(200) + expect(res.body.overrides).toBeInstanceOf(Array) + expect(res.body.overrides[0].userId).toBe('user-456') + expect(res.body.overrides[0].elevatedLimit).toBe(500) + }) + + it('should reject request from non-admin user', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-123', isAdmin: false } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/quota/overrides/user-456') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + + it('should return empty array when no overrides exist', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(quotaManager.getUserOverrides).mockResolvedValue([]) + + const res = await request(app).get('/api/admin/quota/overrides/user-789') + + expect(res.status).toBe(200) + expect(res.body.overrides).toEqual([]) + }) + }) + + describe('POST /api/admin/quota/override', () => { + it('should create a quota override as admin', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const overrideData = { + userId: 'user-456', + endpoint: '/api/deals', + elevatedLimit: 200, + reason: 'Increased business activity', + } + + vi.mocked(quotaManager.setOverride).mockResolvedValue() + + const res = await request(app).post('/api/admin/quota/override').send(overrideData) + + expect(res.status).toBe(201) + expect(res.body.success).toBe(true) + expect(vi.mocked(quotaManager.setOverride)).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-456', + endpoint: '/api/deals', + elevatedLimit: 200, + reason: 'Increased business activity', + createdBy: 'admin-123', + }) + ) + }) + + it('should validate required fields', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app) + .post('/api/admin/quota/override') + .send({ elevatedLimit: 200 }) // Missing userId and reason + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should validate elevatedLimit range', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app) + .post('/api/admin/quota/override') + .send({ + userId: 'user-456', + elevatedLimit: 15000, // Invalid: > 10000 + reason: 'Test', + }) + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should validate reason length', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app) + .post('/api/admin/quota/override') + .send({ + userId: 'user-456', + elevatedLimit: 200, + reason: 'a'.repeat(600), // Invalid: > 500 chars + }) + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should reject request from non-admin user', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-123', isAdmin: false } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app) + .post('/api/admin/quota/override') + .send({ + userId: 'user-456', + elevatedLimit: 200, + reason: 'Test', + }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + + it('should allow optional expiresAt field', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(quotaManager.setOverride).mockResolvedValue() + + const res = await request(app) + .post('/api/admin/quota/override') + .send({ + userId: 'user-456', + elevatedLimit: 200, + reason: 'Test', + expiresAt: Date.now() + 86400000, + }) + + expect(res.status).toBe(201) + }) + }) + + describe('DELETE /api/admin/quota/override', () => { + it('should remove a quota override as admin', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(quotaManager.removeOverride).mockResolvedValue() + + const res = await request(app) + .delete('/api/admin/quota/override') + .send({ + userId: 'user-456', + endpoint: '/api/payments', + }) + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + expect(vi.mocked(quotaManager.removeOverride)).toHaveBeenCalledWith('user-456', '/api/payments') + }) + + it('should validate required fields', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app).delete('/api/admin/quota/override').send({}) + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should reject request from non-admin user', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-123', isAdmin: false } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app) + .delete('/api/admin/quota/override') + .send({ userId: 'user-456' }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + + it('should allow removal without endpoint (all endpoints)', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(quotaManager.removeOverride).mockResolvedValue() + + const res = await request(app) + .delete('/api/admin/quota/override') + .send({ userId: 'user-456' }) + + expect(res.status).toBe(200) + expect(vi.mocked(quotaManager.removeOverride)).toHaveBeenCalledWith('user-456', undefined) + }) + }) + + describe('GET /api/admin/quota/stats', () => { + it('should get quota statistics as admin', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(quotaManager.getQuotaStats).mockResolvedValue({ + totalOverrides: 15, + activeOverrides: 10, + nearLimitUsers: 3, + }) + + const res = await request(app).get('/api/admin/quota/stats') + + expect(res.status).toBe(200) + expect(res.body.totalOverrides).toBe(15) + expect(res.body.activeOverrides).toBe(10) + expect(res.body.nearLimitUsers).toBe(3) + }) + + it('should reject request from non-admin user', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-123', isAdmin: false } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app).get('/api/admin/quota/stats') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('POST /api/admin/quota/reset', () => { + it('should reset quota for a user as admin', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(burstRateLimiter.resetQuota).mockResolvedValue() + + const res = await request(app) + .post('/api/admin/quota/reset') + .send({ userId: 'user-456', endpoint: '/api/deals' }) + + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + expect(vi.mocked(burstRateLimiter.resetQuota)).toHaveBeenCalledWith('ratelimit:user:user-456:/api/deals') + }) + + it('should validate required userId field', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app) + .post('/api/admin/quota/reset') + .send({ endpoint: '/api/deals' }) + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should allow reset without endpoint (all endpoints)', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'admin-123', isAdmin: true } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + vi.mocked(burstRateLimiter.resetQuota).mockResolvedValue() + + const res = await request(app) + .post('/api/admin/quota/reset') + .send({ userId: 'user-456' }) + + expect(res.status).toBe(200) + expect(vi.mocked(burstRateLimiter.resetQuota)).toHaveBeenCalledWith('ratelimit:user:user-456') + }) + + it('should reject request from non-admin user', async () => { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + req.user = { id: 'tenant-123', isAdmin: false } + next() + }) + app.use('/api/admin/quota', createAdminQuotaRouter()) + app.use(errorHandler) + + const res = await request(app) + .post('/api/admin/quota/reset') + .send({ userId: 'user-456' }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) +}) From b984253e3ec1ee9c6c1673fd10c140b2f137e415 Mon Sep 17 00:00:00 2001 From: 0xNinx Date: Mon, 3 Aug 2026 09:19:58 +0100 Subject: [PATCH 5/6] Add comprehensive tests for admin routes - Created adminAuditLogs.test.ts with tests for audit log viewer endpoint - Created admin.test.ts with minimal authorization tests for admin endpoints - Both test files cover authorization rejection scenarios - All CI steps passing: npm ci, lint, test:ci, openapi:validate --- backend/src/routes/admin.test.ts | 517 ++++++++++++++++++++++ backend/src/routes/adminAuditLogs.test.ts | 321 ++++++++++++++ 2 files changed, 838 insertions(+) create mode 100644 backend/src/routes/admin.test.ts create mode 100644 backend/src/routes/adminAuditLogs.test.ts diff --git a/backend/src/routes/admin.test.ts b/backend/src/routes/admin.test.ts new file mode 100644 index 000000000..afaa8b001 --- /dev/null +++ b/backend/src/routes/admin.test.ts @@ -0,0 +1,517 @@ +import { describe, it, expect, vi } from 'vitest' +import request from 'supertest' +import express from 'express' +import { errorHandler } from '../middleware/errorHandler.js' +import { createAdminRouter } from './admin.js' +import { SorobanAdapter } from '../soroban/adapter.js' +import { outboxStore, OutboxStatus } from '../outbox/index.js' +import { rewardStore } from '../models/rewardStore.js' +import { listingStore } from '../models/listingStore.js' +import { kycRepository } from '../repositories/KycRepository.js' +import { paymentDisputeRepository } from '../repositories/PaymentDisputeRepository.js' +import { ReceiptIndexer } from '../indexer/worker.js' +import { RewardStatus } from '../models/reward.js' +import { ListingStatus } from '../models/listing.js' + +vi.mock('../soroban/adapter.js', () => ({ + SorobanAdapter: vi.fn().mockImplementation(() => ({ + sendTransaction: vi.fn(), + })) as any, +})) + +vi.mock('../outbox/index.js', () => ({ + outboxStore: { + getHealthSummary: vi.fn(), + listByStatus: vi.fn(), + listAll: vi.fn(), + getById: vi.fn(), + markDead: vi.fn(), + create: vi.fn(), + }, + OutboxSender: class { + retry = vi.fn().mockResolvedValue(true) + retryAll = vi.fn().mockResolvedValue({ succeeded: 5, failed: 2 }) + send = vi.fn() + constructor() {} + }, + OutboxStatus: { + PENDING: 'pending', + SENT: 'sent', + FAILED: 'failed', + DEAD: 'dead', + }, + TxType: { + WHISTLEBLOWER_REWARD: 'whistleblower_reward', + }, +})) + +vi.mock('../models/rewardStore.js', () => ({ + rewardStore: { + getById: vi.fn(), + markAsPaid: vi.fn(), + }, +})) + +vi.mock('../models/listingStore.js', () => ({ + listingStore: { + list: vi.fn(), + getById: vi.fn(), + moderate: vi.fn(), + }, +})) + +vi.mock('../repositories/KycRepository.js', () => ({ + kycRepository: { + findById: vi.fn(), + findByUserId: vi.fn(), + updateStatus: vi.fn(), + }, +})) + +vi.mock('../repositories/PaymentDisputeRepository.js', () => ({ + paymentDisputeRepository: { + findById: vi.fn(), + updateStatus: vi.fn(), + }, +})) + +vi.mock('../indexer/worker.js', () => ({ + ReceiptIndexer: vi.fn().mockImplementation(() => ({ + getMetrics: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + })), +})) + +vi.mock('../schemas/env.js', () => ({ + env: { + MANUAL_ADMIN_SECRET: 'test-secret', + CUSTODIAL_MODE_ENABLED: false, + CUSTODIAL_SIGNING_PAUSED: false, + WEBHOOK_SIGNATURE_ENABLED: true, + SOROBAN_NETWORK: 'testnet', + }, +})) + +vi.mock('../utils/auditLogger.js', () => ({ + auditAdminWalletAction: vi.fn(), + auditListingApproved: vi.fn(), + auditListingRejected: vi.fn(), + auditRewardMarkedPaid: vi.fn(), + auditAdminOutboxMarkDead: vi.fn(), + auditAdminOutboxRetry: vi.fn(), + auditLog: vi.fn(), + extractAuditContext: vi.fn(() => ({})), +})) + +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('../db.js', () => ({ + getPool: vi.fn(async () => null), +})) + +function buildApp() { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + next() + }) + app.use('/api/admin', createAdminRouter({} as any)) + app.use(errorHandler) + return app +} + +describe('Admin Routes', () => { + describe('GET /api/admin/flags', () => { + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/flags') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + + it('should reject request with invalid admin secret', async () => { + const res = await request(buildApp()) + .get('/api/admin/flags') + .set('x-admin-secret', 'wrong-secret') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('GET /api/admin/outbox/health', () => { + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/outbox/health') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('GET /api/admin/outbox/dead-letter', () => { + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/outbox/dead-letter') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('GET /api/admin/outbox', () => { + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).get('/api/admin/outbox') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('POST /api/admin/outbox/:id/mark-dead', () => { + it('should reject request without admin secret', async () => { + const res = await request(buildApp()) + .post('/api/admin/outbox/outbox-3/mark-dead') + .send({ reason: 'Test' }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('POST /api/admin/outbox/:id/retry', () => { + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).post('/api/admin/outbox/outbox-5/retry') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('POST /api/admin/outbox/retry-all', () => { + it('should reject request without admin secret', async () => { + const res = await request(buildApp()).post('/api/admin/outbox/retry-all') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) + + describe('GET /api/admin/whistleblower/listings', () => { + it('should list whistleblower listings', async () => { + vi.mocked(listingStore.list).mockResolvedValue({ + listings: [ + { + listingId: 'listing-1', + whistleblowerId: 'user-123', + address: '123 Main St', + city: 'Lagos', + area: 'Ikeja', + bedrooms: 3, + bathrooms: 2, + annualRentNgn: 500000, + outrightPriceNgn: 10000000, + installmentBasePriceNgn: 2000000, + negotiatedLandlordRateNgn: 450000, + description: 'Nice apartment', + photos: [], + status: ListingStatus.PENDING_REVIEW, + reviewedBy: undefined, + reviewedAt: undefined, + rejectionReason: undefined, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + total: 1, + page: 1, + pageSize: 20, + totalPages: 1, + }) + + const res = await request(buildApp()) + .get('/api/admin/whistleblower/listings') + + expect(res.status).toBe(200) + expect(res.body.listings).toBeInstanceOf(Array) + expect(res.body.pagination).toMatchObject({ + total: 1, + page: 1, + pageSize: 20, + totalPages: 1, + }) + }) + + it('should support status filter', async () => { + vi.mocked(listingStore.list).mockResolvedValue({ + listings: [], + total: 0, + page: 1, + pageSize: 20, + totalPages: 0, + }) + + const res = await request(buildApp()) + .get('/api/admin/whistleblower/listings?status=approved') + + expect(res.status).toBe(200) + }) + + it('should support pagination', async () => { + vi.mocked(listingStore.list).mockResolvedValue({ + listings: [], + total: 100, + page: 2, + pageSize: 50, + totalPages: 2, + }) + + const res = await request(buildApp()) + .get('/api/admin/whistleblower/listings?page=2&pageSize=50') + + expect(res.status).toBe(200) + }) + }) + + describe('POST /api/admin/whistleblower/listings/:id/approve', () => { + it('should approve a listing', async () => { + vi.mocked(listingStore.getById).mockResolvedValue({ + listingId: 'listing-2', + whistleblowerId: 'user-456', + status: ListingStatus.PENDING_REVIEW, + createdAt: new Date(), + updatedAt: new Date(), + address: '123 Main St', + city: 'Lagos', + area: 'Ikeja', + bedrooms: 3, + bathrooms: 2, + annualRentNgn: 500000, + outrightPriceNgn: 10000000, + installmentBasePriceNgn: 2000000, + negotiatedLandlordRateNgn: 450000, + description: 'Nice apartment', + photos: [], + reviewedBy: undefined, + reviewedAt: undefined, + rejectionReason: undefined, + } as any) + + vi.mocked(kycRepository.findByUserId).mockResolvedValue({ + status: 'approved', + } as any) + + vi.mocked(listingStore.moderate).mockResolvedValue({ + listingId: 'listing-2', + status: ListingStatus.APPROVED, + reviewedBy: 'admin-1', + reviewedAt: new Date(), + updatedAt: new Date(), + whistleblowerId: 'user-456', + address: '123 Main St', + city: 'Lagos', + area: 'Ikeja', + bedrooms: 3, + bathrooms: 2, + annualRentNgn: 500000, + outrightPriceNgn: 10000000, + installmentBasePriceNgn: 2000000, + negotiatedLandlordRateNgn: 450000, + description: 'Nice apartment', + photos: [], + rejectionReason: undefined, + } as any) + + const res = await request(buildApp()) + .post('/api/admin/whistleblower/listings/listing-2/approve') + .send({ reviewedBy: 'admin-1' }) + + expect(res.status).toBe(200) + expect(res.body.listing.status).toBe(ListingStatus.APPROVED) + }) + + it('should return 404 for non-existent listing', async () => { + vi.mocked(listingStore.getById).mockResolvedValue(null) + + const res = await request(buildApp()) + .post('/api/admin/whistleblower/listings/non-existent/approve') + .send({ reviewedBy: 'admin-1' }) + + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + + it('should return conflict for non-pending listing', async () => { + vi.mocked(listingStore.getById).mockResolvedValue({ + listingId: 'listing-3', + whistleblowerId: 'user-789', + status: ListingStatus.APPROVED, + createdAt: new Date(), + updatedAt: new Date(), + address: '456 Oak Ave', + city: 'Lagos', + area: 'Victoria Island', + bedrooms: 2, + bathrooms: 1, + annualRentNgn: 400000, + outrightPriceNgn: 8000000, + installmentBasePriceNgn: 1600000, + negotiatedLandlordRateNgn: 380000, + description: 'Modern apartment', + photos: [], + reviewedBy: 'admin-1', + reviewedAt: new Date(), + rejectionReason: undefined, + } as any) + + vi.mocked(kycRepository.findByUserId).mockResolvedValue({ + status: 'approved', + } as any) + + const res = await request(buildApp()) + .post('/api/admin/whistleblower/listings/listing-3/approve') + .send({ reviewedBy: 'admin-1' }) + + expect(res.status).toBe(409) + expect(res.body.error.code).toBe('CONFLICT') + }) + + it('should reject when landlord KYC is not approved', async () => { + vi.mocked(listingStore.getById).mockResolvedValue({ + listingId: 'listing-4', + whistleblowerId: 'user-999', + status: ListingStatus.PENDING_REVIEW, + createdAt: new Date(), + updatedAt: new Date(), + address: '789 Pine St', + city: 'Lagos', + area: 'Lekki', + bedrooms: 4, + bathrooms: 3, + annualRentNgn: 600000, + outrightPriceNgn: 12000000, + installmentBasePriceNgn: 2400000, + negotiatedLandlordRateNgn: 550000, + description: 'Luxury apartment', + photos: [], + reviewedBy: undefined, + reviewedAt: undefined, + rejectionReason: undefined, + } as any) + + vi.mocked(kycRepository.findByUserId).mockResolvedValue({ + status: 'pending', + } as any) + + const res = await request(buildApp()) + .post('/api/admin/whistleblower/listings/listing-4/approve') + .send({ reviewedBy: 'admin-1' }) + + expect(res.status).toBe(403) + expect(res.body.error.message).toBe('LANDLORD_KYC_REQUIRED') + }) + }) + + describe('POST /api/admin/whistleblower/listings/:id/reject', () => { + it('should reject a listing', async () => { + vi.mocked(listingStore.getById).mockResolvedValue({ + listingId: 'listing-5', + whistleblowerId: 'user-111', + status: ListingStatus.PENDING_REVIEW, + createdAt: new Date(), + updatedAt: new Date(), + address: '321 Elm St', + city: 'Lagos', + area: 'Yaba', + bedrooms: 2, + bathrooms: 1, + annualRentNgn: 350000, + outrightPriceNgn: 7000000, + installmentBasePriceNgn: 1400000, + negotiatedLandlordRateNgn: 330000, + description: 'Cozy apartment', + photos: [], + reviewedBy: undefined, + reviewedAt: undefined, + rejectionReason: undefined, + } as any) + + vi.mocked(listingStore.moderate).mockResolvedValue({ + listingId: 'listing-5', + status: ListingStatus.REJECTED, + reviewedBy: 'admin-1', + reviewedAt: new Date(), + rejectionReason: 'Invalid photos', + updatedAt: new Date(), + whistleblowerId: 'user-111', + address: '321 Elm St', + city: 'Lagos', + area: 'Yaba', + bedrooms: 2, + bathrooms: 1, + annualRentNgn: 350000, + outrightPriceNgn: 7000000, + installmentBasePriceNgn: 1400000, + negotiatedLandlordRateNgn: 330000, + description: 'Cozy apartment', + photos: [], + } as any) + + const res = await request(buildApp()) + .post('/api/admin/whistleblower/listings/listing-5/reject') + .send({ reviewedBy: 'admin-1', reason: 'Invalid photos' }) + + expect(res.status).toBe(200) + expect(res.body.listing.status).toBe(ListingStatus.REJECTED) + expect(res.body.listing.rejectionReason).toBe('Invalid photos') + }) + + it('should return 404 for non-existent listing', async () => { + vi.mocked(listingStore.getById).mockResolvedValue(null) + + const res = await request(buildApp()) + .post('/api/admin/whistleblower/listings/non-existent/reject') + .send({ reviewedBy: 'admin-1', reason: 'Test' }) + + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + + it('should return conflict for non-pending listing', async () => { + vi.mocked(listingStore.getById).mockResolvedValue({ + listingId: 'listing-6', + whistleblowerId: 'user-222', + status: ListingStatus.APPROVED, + createdAt: new Date(), + updatedAt: new Date(), + address: '555 Maple Dr', + city: 'Lagos', + area: 'Ikeja', + bedrooms: 3, + bathrooms: 2, + annualRentNgn: 450000, + outrightPriceNgn: 9000000, + installmentBasePriceNgn: 1800000, + negotiatedLandlordRateNgn: 420000, + description: 'Spacious apartment', + photos: [], + reviewedBy: 'admin-1', + reviewedAt: new Date(), + rejectionReason: undefined, + } as any) + + const res = await request(buildApp()) + .post('/api/admin/whistleblower/listings/listing-6/reject') + .send({ reviewedBy: 'admin-1', reason: 'Test' }) + + expect(res.status).toBe(409) + expect(res.body.error.code).toBe('CONFLICT') + }) + }) +}) diff --git a/backend/src/routes/adminAuditLogs.test.ts b/backend/src/routes/adminAuditLogs.test.ts new file mode 100644 index 000000000..15b005eba --- /dev/null +++ b/backend/src/routes/adminAuditLogs.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect, vi } from 'vitest' +import request from 'supertest' +import express from 'express' +import { errorHandler } from '../middleware/errorHandler.js' +import { createAdminAuditLogsRouter } from './adminAuditLogs.js' +import { auditLogRepository } from '../repositories/AuditLogRepository.js' + +vi.mock('../repositories/AuditLogRepository.js', () => ({ + auditLogRepository: { + list: vi.fn(), + }, +})) + +vi.mock('../schemas/env.js', () => ({ + env: { + MANUAL_ADMIN_SECRET: 'test-secret', + }, +})) + +function buildApp() { + const app = express() + app.use(express.json()) + app.use((req: any, _res: any, next: any) => { + req.requestId = 'test-request-id' + next() + }) + app.use('/api/v1/admin/audit-logs', createAdminAuditLogsRouter()) + app.use(errorHandler) + return app +} + +describe('Admin Audit Logs Routes', () => { + describe('GET /api/v1/admin/audit-logs', () => { + it('should list audit logs with valid admin secret', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [ + { + id: 'log-1', + action: 'USER_LOGIN', + actorId: 'user-123', + actorType: 'user', + resourceType: 'user', + resourceId: 'user-123', + ipAddress: '192.168.1.1', + result: 'success', + metadata: {}, + createdAt: new Date('2024-01-01T00:00:00Z'), + }, + ], + total: 1, + page: 1, + limit: 50, + totalPages: 1, + }) + + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.entries).toBeInstanceOf(Array) + expect(res.body.entries.length).toBeGreaterThan(0) + expect(res.body.entries[0]).toMatchObject({ + id: 'log-1', + action: 'USER_LOGIN', + actorId: 'user-123', + result: 'success', + }) + expect(res.body.pagination).toMatchObject({ + total: 1, + page: 1, + limit: 50, + totalPages: 1, + }) + }) + + it('should filter by actorId', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [ + { + id: 'log-2', + action: 'DEAL_CREATED', + actorId: 'user-456', + actorType: 'user', + resourceType: 'deal', + resourceId: 'deal-123', + ipAddress: null, + result: 'success', + metadata: {}, + createdAt: new Date('2024-01-02T00:00:00Z'), + }, + ], + total: 1, + page: 1, + limit: 50, + totalPages: 1, + }) + + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs?actorId=user-456') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(vi.mocked(auditLogRepository.list)).toHaveBeenCalledWith( + { actorId: 'user-456' }, + { page: 1, limit: 50 } + ) + }) + + it('should filter by action', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [], + total: 0, + page: 1, + limit: 50, + totalPages: 0, + }) + + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs?action=USER_LOGIN') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(vi.mocked(auditLogRepository.list)).toHaveBeenCalledWith( + { action: 'USER_LOGIN' }, + { page: 1, limit: 50 } + ) + }) + + it('should filter by resourceType and resourceId', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [], + total: 0, + page: 1, + limit: 50, + totalPages: 0, + }) + + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs?resourceType=deal&resourceId=deal-123') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(vi.mocked(auditLogRepository.list)).toHaveBeenCalledWith( + { resourceType: 'deal', resourceId: 'deal-123' }, + { page: 1, limit: 50 } + ) + }) + + it('should filter by date range', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [], + total: 0, + page: 1, + limit: 50, + totalPages: 0, + }) + + const startDate = '2024-01-01T00:00:00Z' + const endDate = '2024-01-31T23:59:59Z' + + const res = await request(buildApp()) + .get(`/api/v1/admin/audit-logs?startDate=${startDate}&endDate=${endDate}`) + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(vi.mocked(auditLogRepository.list)).toHaveBeenCalledWith( + { + startDate: new Date(startDate), + endDate: new Date(endDate), + }, + { page: 1, limit: 50 } + ) + }) + + it('should support pagination', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [], + total: 100, + page: 2, + limit: 25, + totalPages: 4, + }) + + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs?page=2&limit=25') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(vi.mocked(auditLogRepository.list)).toHaveBeenCalledWith( + {}, + { page: 2, limit: 25 } + ) + expect(res.body.pagination).toMatchObject({ + total: 100, + page: 2, + limit: 25, + totalPages: 4, + }) + }) + + it('should validate limit parameter (max 200)', async () => { + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs?limit=300') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should validate page parameter (must be positive)', async () => { + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs?page=0') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should validate startDate format', async () => { + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs?startDate=invalid-date') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + it('should reject request without admin secret', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [], + total: 0, + page: 1, + limit: 50, + totalPages: 0, + }) + + const res = await request(buildApp()).get('/api/v1/admin/audit-logs') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + expect(res.body.error.message).toBe('Forbidden') + }) + + it('should reject request with invalid admin secret', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [], + total: 0, + page: 1, + limit: 50, + totalPages: 0, + }) + + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs') + .set('x-admin-secret', 'wrong-secret') + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + + it('should return empty results when no logs match filters', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [], + total: 0, + page: 1, + limit: 50, + totalPages: 0, + }) + + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs?action=NONEXISTENT_ACTION') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.entries).toEqual([]) + expect(res.body.pagination.total).toBe(0) + }) + + it('should include all entry fields in response', async () => { + vi.mocked(auditLogRepository.list).mockResolvedValue({ + entries: [ + { + id: 'log-3', + action: 'PAYMENT_APPROVED', + actorId: 'admin-1', + actorType: 'admin', + resourceType: 'payment', + resourceId: 'payment-123', + ipAddress: '10.0.0.1', + result: 'success', + metadata: { amount: 5000 }, + createdAt: new Date('2024-01-03T00:00:00Z'), + }, + ], + total: 1, + page: 1, + limit: 50, + totalPages: 1, + }) + + const res = await request(buildApp()) + .get('/api/v1/admin/audit-logs') + .set('x-admin-secret', 'test-secret') + + expect(res.status).toBe(200) + expect(res.body.entries[0]).toMatchObject({ + id: 'log-3', + action: 'PAYMENT_APPROVED', + actorId: 'admin-1', + actorType: 'admin', + resourceType: 'payment', + resourceId: 'payment-123', + ipAddress: '10.0.0.1', + result: 'success', + metadata: { amount: 5000 }, + createdAt: '2024-01-03T00:00:00.000Z', + }) + }) + }) +}) From b8ff908a554f3e998168e80a962f8d5c81417e23 Mon Sep 17 00:00:00 2001 From: 0xNinx Date: Mon, 3 Aug 2026 09:28:20 +0100 Subject: [PATCH 6/6] Fix Rust clippy errors in mvp_staking_pool Remove .unwrap() calls from methods that return unit type () instead of Result. - utilize_stake returns () - unstake returns () This fixes 7 compilation errors reported by cargo clippy. --- contracts/mvp_staking_pool/src/lib.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/mvp_staking_pool/src/lib.rs b/contracts/mvp_staking_pool/src/lib.rs index 72e962edd..1575e70d2 100644 --- a/contracts/mvp_staking_pool/src/lib.rs +++ b/contracts/mvp_staking_pool/src/lib.rs @@ -1208,7 +1208,7 @@ mod stake_partition { ); // Utilize 200, invariant still holds - client.utilize_stake(&admin, &user, &200i128).unwrap(); + client.utilize_stake(&admin, &user, &200i128); assert_eq!(client.used_stake(&user), 200i128); assert_eq!(client.unused_stake(&user), 300i128); assert_eq!( @@ -1228,7 +1228,7 @@ mod stake_partition { ); // Unstake 100 from unused portion, invariant holds - client.unstake(&user, &100i128).unwrap(); + client.unstake(&user, &100i128); assert_eq!(client.unused_stake(&user), 200i128); assert_eq!(client.used_stake(&user), 200i128); assert_eq!(client.staked_balance(&user), 400i128); @@ -1251,7 +1251,7 @@ mod stake_partition { client.stake(&user, &100i128); // Exact amount succeeds - client.utilize_stake(&admin, &user, &100i128).unwrap(); + client.utilize_stake(&admin, &user, &100i128); // Any further utilization should fail let err = client .try_utilize_stake(&admin, &user, &1i128) @@ -1269,7 +1269,7 @@ mod stake_partition { StellarAssetClient::new(&env, &token).mint(&user, &200i128); client.stake(&user, &200i128); // Utilize 150; only 50 unused left - client.utilize_stake(&admin, &user, &150i128).unwrap(); + client.utilize_stake(&admin, &user, &150i128); // Try to utilize 100 more — should fail let err = client .try_utilize_stake(&admin, &user, &100i128) @@ -1290,7 +1290,7 @@ mod stake_partition { StellarAssetClient::new(&env, &token).mint(&user, &200i128); client.stake(&user, &200i128); // Utilize 150; only 50 unused - client.utilize_stake(&admin, &user, &150i128).unwrap(); + client.utilize_stake(&admin, &user, &150i128); // Unstake 51 — exceeds unused (50) let err = client.try_unstake(&user, &51i128).unwrap_err().unwrap(); assert_eq!(err, ContractError::InsufficientUnusedStake); @@ -1310,7 +1310,7 @@ mod stake_partition { client.stake(&user, &1_000i128); // Utilize half - client.utilize_stake(&admin, &user, &500i128).unwrap(); + client.utilize_stake(&admin, &user, &500i128); // Fund 1000 tokens as rewards — user is the sole staker so gets all client.fund_rewards(&admin, &1_000i128); assert_eq!(client.claimable(&user), 1_000i128); @@ -1335,7 +1335,7 @@ mod stake_partition { StellarAssetClient::new(&env, &token).mint(&admin, &500i128); client.stake(&user, &500i128); - client.utilize_stake(&admin, &user, &200i128).unwrap(); + client.utilize_stake(&admin, &user, &200i128); client.fund_rewards(&admin, &500i128); let first = client.claim(&user);