diff --git a/backend/src/middleware/auditLog.ts b/backend/src/middleware/auditLog.ts index 78dc5a36..4f400c49 100644 --- a/backend/src/middleware/auditLog.ts +++ b/backend/src/middleware/auditLog.ts @@ -2,6 +2,37 @@ import type { Request, Response, NextFunction } from 'express'; import { query } from '../db/connection.js'; import logger from '../utils/logger.js'; +const AUDIT_LOG_TIMEOUT_MS = Number(process.env.AUDIT_LOG_TIMEOUT_MS ?? 750); + +async function persistAuditLog( + actor: string, + action: string, + target: string | undefined, + payload: unknown, + ipAddress: string | undefined, + statusCode: number, +): Promise { + await Promise.race([ + query( + `INSERT INTO audit_logs (actor, action, target, payload, ip_address, status) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + actor, + action, + target ?? null, + payload ? JSON.stringify(payload) : null, + ipAddress ?? null, + statusCode, + ], + ), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Audit log insert timed out after ${AUDIT_LOG_TIMEOUT_MS}ms`)); + }, AUDIT_LOG_TIMEOUT_MS); + }), + ]); +} + /** * Sanitizes the request body to remove sensitive fields before logging. */ @@ -79,31 +110,27 @@ export const auditLog = (req: Request, res: Response, next: NextFunction): void )?.split(',')[0] || req.socket.remoteAddress; + const isJestTestRun = !!process.env.JEST_WORKER_ID; + const allowTestAuditLogging = process.env.AUDIT_LOG_ALLOW_IN_TESTS === '1'; + res.on('finish', () => { - // Log the action asynchronously to avoid blocking the main request thread - void (async () => { - try { - await query( - `INSERT INTO audit_logs (actor, action, target, payload, ip_address, status) - VALUES ($1, $2, $3, $4, $5, $6)`, - [ - actor, - action, - target ?? null, - payload ? JSON.stringify(payload) : null, - ipAddress ?? null, - res.statusCode, - ], - ); - } catch (err) { + if (isJestTestRun && !allowTestAuditLogging) { + return; + } + + // Log the action asynchronously to avoid blocking the main request thread, + // but cap the wait so a slow or unavailable DB does not keep Jest or the + // Node process alive after the response has already completed. + void persistAuditLog(actor, action, target, payload, ipAddress, res.statusCode).catch( + (err) => { logger.error('Audit logging failure', { err, actor, action, target, }); - } - })(); + }, + ); }); } catch (err) { // If the audit log logic fails, we still want to proceed with the request diff --git a/backend/src/services/__tests__/auditLogService.pagination.test.ts b/backend/src/services/__tests__/auditLogService.pagination.test.ts index 28d35926..95b12899 100644 --- a/backend/src/services/__tests__/auditLogService.pagination.test.ts +++ b/backend/src/services/__tests__/auditLogService.pagination.test.ts @@ -1,10 +1,10 @@ -import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import type { AuditLogFilters } from '../auditLogService.js'; -// getAuditLogs talks to Postgres through query() — mock it so these tests -// assert the SQL it builds (ordering, keyset predicate, filtered count) -// without needing a database. -const mockQuery = jest.fn(); +const mockQuery = jest + .fn<(...args: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }>>() + .mockResolvedValue({ rows: [], rowCount: 0 }); + jest.unstable_mockModule('../../db/connection.js', () => ({ query: mockQuery, })); @@ -76,8 +76,6 @@ describe('getAuditLogs keyset pagination and totals (#1808)', () => { // The cursor carries the timestamp *and* the id it is paging from. It // must split on the LAST ':' — ISO timestamps contain colons. expect(result.nextCursor).toContain(':'); - // Use decodeCursor to properly parse the composite cursor - const { decodeCursor } = await import('../auditLogService.js'); const decoded = decodeCursor(result.nextCursor); expect(decoded).not.toBeNull(); expect(decoded!.createdAt).toMatch(/^2026-03-02T/); @@ -120,6 +118,7 @@ describe('getAuditLogs keyset pagination and totals (#1808)', () => { mockQuery.mockImplementation((text: unknown) => Promise.resolve({ rows: String(text).includes('SELECT * FROM audit_logs') ? PAGE_ROWS : [{ count: 3 }], + rowCount: String(text).includes('SELECT * FROM audit_logs') ? PAGE_ROWS.length : 1, }), ); @@ -149,6 +148,7 @@ describe('getAuditLogs keyset pagination and totals (#1808)', () => { mockQuery.mockImplementation((text: unknown) => Promise.resolve({ rows: String(text).includes('SELECT * FROM audit_logs') ? PAGE_ROWS : [{ count: 7 }], + rowCount: String(text).includes('SELECT * FROM audit_logs') ? PAGE_ROWS.length : 1, }), ); @@ -164,6 +164,7 @@ describe('getAuditLogs keyset pagination and totals (#1808)', () => { mockQuery.mockImplementation((text: unknown) => Promise.resolve({ rows: String(text).includes('SELECT * FROM audit_logs') ? PAGE_ROWS : [{ count: 137 }], + rowCount: String(text).includes('SELECT * FROM audit_logs') ? PAGE_ROWS.length : 1, }), ); diff --git a/backend/src/services/__tests__/yieldHistoryService.test.ts b/backend/src/services/__tests__/yieldHistoryService.test.ts index 060f7925..432bcb08 100644 --- a/backend/src/services/__tests__/yieldHistoryService.test.ts +++ b/backend/src/services/__tests__/yieldHistoryService.test.ts @@ -106,7 +106,34 @@ describe('yieldHistoryService', () => { // After withdrawing half the shares the cost basis should have halved const latest = history[history.length - 1]!; // netYield = currentValue - costBasis; costBasis after withdraw ≈ 500 - expect(latest.netYield).toBeGreaterThanOrEqual(-1); // may be slightly negative due to share price + // currentSharePrice=500_000 → currentValue = 250, so netYield = -250 + expect(latest.netYield).toBe(-250); + }); + + it('returns negative netYield when currentValue < depositedValue', async () => { + const now = new Date(); + const t1 = new Date(now); + t1.setUTCDate(t1.getUTCDate() - 2); + + // Pool events: single deposit + mockQuery.mockResolvedValueOnce({ + rows: [{ event_type: 'Deposit', amount: '1000', ledger_closed_at: t1, value: null }], + }); + // Depositor events: single deposit + mockQuery.mockResolvedValueOnce({ + rows: [{ event_type: 'Deposit', amount: '1000', ledger_closed_at: t1, value: null }], + }); + + // currentSharePrice = 500_000 means 0.5 XLM per share + // Depositor has 1000 shares → currentValue = 500 + // Cost basis = 1000 → netYield = 500 - 1000 = -500 (negative!) + const history = await buildDepositorYieldHistory('GDep', 'GTok', 7, 500_000); + + expect(history.length).toBeGreaterThan(0); + const latest = history[history.length - 1]!; + expect(latest.depositedValue).toBe(1000); + expect(latest.currentValue).toBe(500); + expect(latest.netYield).toBe(-500); }); it('EmergencyWithdraw follows the same cost-basis reduction path as Withdraw', async () => { diff --git a/backend/src/services/yieldHistoryService.ts b/backend/src/services/yieldHistoryService.ts index a79ddcef..5b63b9b3 100644 --- a/backend/src/services/yieldHistoryService.ts +++ b/backend/src/services/yieldHistoryService.ts @@ -259,7 +259,7 @@ export async function buildDepositorYieldHistory( } const depositedValue = depositorState.costBasis; - const netYield = Math.max(0, currentValue - depositedValue); + const netYield = currentValue - depositedValue; points.push({ timestamp: bucketEnd.toISOString(), diff --git a/backend/src/tests/auditLog.test.ts b/backend/src/tests/auditLog.test.ts index c41e9a3a..0644190b 100644 --- a/backend/src/tests/auditLog.test.ts +++ b/backend/src/tests/auditLog.test.ts @@ -1,5 +1,7 @@ import { jest } from '@jest/globals'; +process.env.AUDIT_LOG_ALLOW_IN_TESTS = '1'; + // Use unstable_mockModule for robust ESM mocking of the connection module. jest.unstable_mockModule('../db/connection.js', () => ({ query: jest.fn(), @@ -20,6 +22,10 @@ describe('Audit Log Middleware', () => { let res: Partial; let next: NextFunction; + afterAll(() => { + delete process.env.AUDIT_LOG_ALLOW_IN_TESTS; + }); + beforeEach(() => { req = { method: 'POST', diff --git a/backend/src/tests/idempotency.namespace.test.ts b/backend/src/tests/idempotency.namespace.test.ts index 6ded2ed4..7e320e72 100644 --- a/backend/src/tests/idempotency.namespace.test.ts +++ b/backend/src/tests/idempotency.namespace.test.ts @@ -44,6 +44,11 @@ describe('idempotencyMiddleware key namespacing (#1809)', () => { return request; }; + const aliceCacheKey = `idemp:${ALICE}:shared-key`; + const aliceLockKey = `idemp:${ALICE}:shared-key:lock`; + const bobCacheKey = `idemp:${BOB}:shared-key`; + const bobLockKey = `idemp:${BOB}:shared-key:lock`; + const cacheKeysRead = () => asMock(cacheService.get).mock.calls.map(([key]) => String(key)); beforeEach(() => { @@ -175,8 +180,7 @@ describe('idempotencyMiddleware key namespacing (#1809)', () => { }); it('namespaces the lock key as well as the cache key', async () => { - asMock(cacheService.setNotExists).mockResolvedValue(false); - + // Use the beforeEach mock which returns true for Alice's lock key await idempotencyMiddleware(req as Request, res as Response, next); const lockKey = asMock(cacheService.setNotExists).mock.calls[0][0];