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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 45 additions & 18 deletions backend/src/middleware/auditLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<never>((_, 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.
*/
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}));
Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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,
}),
);

Expand Down Expand Up @@ -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,
}),
);

Expand All @@ -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,
}),
);

Expand Down
29 changes: 28 additions & 1 deletion backend/src/services/__tests__/yieldHistoryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/yieldHistoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions backend/src/tests/auditLog.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -20,6 +22,10 @@ describe('Audit Log Middleware', () => {
let res: Partial<Response>;
let next: NextFunction;

afterAll(() => {
delete process.env.AUDIT_LOG_ALLOW_IN_TESTS;
});

beforeEach(() => {
req = {
method: 'POST',
Expand Down
8 changes: 6 additions & 2 deletions backend/src/tests/idempotency.namespace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
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(() => {
Expand Down Expand Up @@ -175,8 +180,7 @@
});

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];
Expand Down
Loading