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
142 changes: 142 additions & 0 deletions src/routes/audit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import express from 'express';
import request from 'supertest';

jest.mock('../middleware/requireAuth.js', () => ({
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as any).developerId = 'dev-user-123';
next();
}
}));

import { createAuditRouter } from './audit.js';
import { errorHandler } from '../middleware/errorHandler.js';

describe('/api/audit mutations', () => {
let app: express.Express;
let recordMock: jest.Mock;

beforeEach(() => {
recordMock = jest.fn().mockResolvedValue(undefined);
app = express();
app.use(express.json());

// Inject a dummy auditContext
app.use((req, _res, next) => {
(req as any).auditContext = {
tenantId: 'tenant-1',
clientIp: '127.0.0.1',
userAgent: 'test-agent',
correlationId: 'corr-1',
bodyHash: 'hash-1',
};
next();
});

app.use('/api/audit', createAuditRouter({ auditService: { record: recordMock } }));
app.use(errorHandler);
});

afterEach(() => {
jest.clearAllMocks();
});

it('GET /api/audit returns empty list initially', async () => {
const res = await request(app).get('/api/audit');
expect(res.status).toBe(200);
expect(res.body.data).toBeInstanceOf(Array);
});

it('POST /api/audit creates a new config and logs AUDIT_CONFIG_CREATE', async () => {
const res = await request(app).post('/api/audit').send({
targetEndpoint: '/users',
enabled: false
});

expect(res.status).toBe(201);
expect(res.body.targetEndpoint).toBe('/users');
expect(res.body.enabled).toBe(false);

expect(recordMock).toHaveBeenCalledTimes(1);
const callArgs = recordMock.mock.calls[0][0];
expect(callArgs.event).toBe('AUDIT_CONFIG_CREATE');
expect(callArgs.actor).toBe('dev-user-123');
expect(callArgs.correlationId).toBe('corr-1');
expect(callArgs.details).toMatchObject({
auditConfigId: res.body.id,
before: null,
after: { targetEndpoint: '/users', enabled: false }
});
});

it('POST /api/audit rejects invalid targetEndpoint', async () => {
const res = await request(app).post('/api/audit').send({
enabled: true
});
expect(res.status).toBe(400);
expect(recordMock).not.toHaveBeenCalled();
});

it('PUT /api/audit/:id updates config and logs AUDIT_CONFIG_UPDATE', async () => {
// Create first
const createRes = await request(app).post('/api/audit').send({ targetEndpoint: '/v1', enabled: true });
const id = createRes.body.id;
recordMock.mockClear();

// Update
const updateRes = await request(app).put(`/api/audit/${id}`).send({ targetEndpoint: '/v2' });
expect(updateRes.status).toBe(200);
expect(updateRes.body.targetEndpoint).toBe('/v2');
expect(updateRes.body.enabled).toBe(true); // kept old value

expect(recordMock).toHaveBeenCalledTimes(1);
const callArgs = recordMock.mock.calls[0][0];
expect(callArgs.event).toBe('AUDIT_CONFIG_UPDATE');
expect(callArgs.details.before).toEqual({ targetEndpoint: '/v1', enabled: true });
expect(callArgs.details.after).toEqual({ targetEndpoint: '/v2', enabled: true });
});

it('PUT /api/audit/:id rejects invalid data', async () => {
const createRes = await request(app).post('/api/audit').send({ targetEndpoint: '/v1', enabled: true });
const id = createRes.body.id;

const res = await request(app).put(`/api/audit/${id}`).send({ enabled: 'not-a-bool' });
expect(res.status).toBe(400);
});

it('PUT /api/audit/:id returns 404 for unknown ID', async () => {
const res = await request(app).put('/api/audit/9999').send({ targetEndpoint: '/x' });
expect(res.status).toBe(404);
});

it('DELETE /api/audit/:id deletes config and logs AUDIT_CONFIG_DELETE', async () => {
const createRes = await request(app).post('/api/audit').send({ targetEndpoint: '/del', enabled: false });
const id = createRes.body.id;
recordMock.mockClear();

const delRes = await request(app).delete(`/api/audit/${id}`);
expect(delRes.status).toBe(204);

expect(recordMock).toHaveBeenCalledTimes(1);
const callArgs = recordMock.mock.calls[0][0];
expect(callArgs.event).toBe('AUDIT_CONFIG_DELETE');
expect(callArgs.details.before).toEqual({ targetEndpoint: '/del', enabled: false });
expect(callArgs.details.after).toBeNull();
});

it('DELETE /api/audit/:id returns 404 for unknown ID', async () => {
const res = await request(app).delete('/api/audit/9999');
expect(res.status).toBe(404);
});

it('does not fail request if audit logging fails', async () => {
recordMock.mockRejectedValueOnce(new Error('DB error'));

const res = await request(app).post('/api/audit').send({
targetEndpoint: '/fail-log',
enabled: true
});

expect(res.status).toBe(201); // Request still succeeds
expect(recordMock).toHaveBeenCalledTimes(1);
});
});
167 changes: 167 additions & 0 deletions src/routes/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Router, type Request } from 'express';
import { defaultAuditService, type AuditService } from '../services/auditService.js';
import { logger } from '../logger.js';
import { NotFoundError, BadRequestError } from '../errors/index.js';
import { requireAuth } from '../middleware/requireAuth.js';

export interface AuditConfigRecord {
id: string;
targetEndpoint: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}

export interface AuditRouterDeps {
auditService?: AuditService;
}

const auditStore: AuditConfigRecord[] = [];
let nextId = 1;

export function createAuditRouter(deps: AuditRouterDeps = {}): Router {
const router = Router();
const auditService = deps.auditService ?? defaultAuditService;

async function recordAudit(
req: Request,
event: string,
actor: string,
details: Record<string, unknown>,
): Promise<void> {
const ctx = req.auditContext;
try {
await auditService.record({
event,
actor,
tenantId: ctx?.tenantId ?? null,
clientIp: ctx?.clientIp ?? null,
userAgent: ctx?.userAgent ?? null,
correlationId: ctx?.correlationId ?? null,
bodyHash: ctx?.bodyHash ?? null,
details,
});
} catch (error) {
logger.error(
{ event, actor, correlationId: ctx?.correlationId, err: error },
'Failed to persist audit log for /api/audit mutation',
);
}
}

router.get('/', (_req, res) => {
res.json({ data: auditStore });
});

router.post('/', requireAuth, async (req, res, next) => {
try {
const { targetEndpoint, enabled } = req.body ?? {};

if (!targetEndpoint || typeof targetEndpoint !== 'string' || targetEndpoint.trim().length === 0) {
next(new BadRequestError('targetEndpoint is required and must be a non-empty string'));
return;
}

const isEnabled = typeof enabled === 'boolean' ? enabled : true;

const id = String(nextId++);
const now = new Date().toISOString();
const record: AuditConfigRecord = {
id,
targetEndpoint: targetEndpoint.trim(),
enabled: isEnabled,
createdAt: now,
updatedAt: now,
};

auditStore.push(record);

const actor = req.developerId ?? 'anonymous';

await recordAudit(req, 'AUDIT_CONFIG_CREATE', actor, {
auditConfigId: id,
before: null,
after: { targetEndpoint: record.targetEndpoint, enabled: record.enabled },
});

res.status(201).json(record);
} catch (error) {
next(error);
}
});

router.put('/:id', requireAuth, async (req, res, next) => {
try {
const { id } = req.params;
const index = auditStore.findIndex((r) => r.id === id);

if (index === -1) {
next(new NotFoundError(`Audit config record ${id} not found`));
return;
}

const existing = auditStore[index]!;
const { targetEndpoint, enabled } = req.body ?? {};

if (targetEndpoint !== undefined && (typeof targetEndpoint !== 'string' || targetEndpoint.trim().length === 0)) {
next(new BadRequestError('targetEndpoint must be a non-empty string'));
return;
}

if (enabled !== undefined && typeof enabled !== 'boolean') {
next(new BadRequestError('enabled must be a boolean'));
return;
}

const updated: AuditConfigRecord = {
...existing,
targetEndpoint: targetEndpoint !== undefined ? targetEndpoint.trim() : existing.targetEndpoint,
enabled: enabled !== undefined ? enabled : existing.enabled,
updatedAt: new Date().toISOString(),
};

auditStore[index] = updated;

const actor = req.developerId ?? 'anonymous';

await recordAudit(req, 'AUDIT_CONFIG_UPDATE', actor, {
auditConfigId: id,
before: { targetEndpoint: existing.targetEndpoint, enabled: existing.enabled },
after: { targetEndpoint: updated.targetEndpoint, enabled: updated.enabled },
});

res.json(updated);
} catch (error) {
next(error);
}
});

router.delete('/:id', requireAuth, async (req, res, next) => {
try {
const { id } = req.params;
const index = auditStore.findIndex((r) => r.id === id);

if (index === -1) {
next(new NotFoundError(`Audit config record ${id} not found`));
return;
}

const removed = auditStore.splice(index, 1)[0]!;
const actor = req.developerId ?? 'anonymous';

await recordAudit(req, 'AUDIT_CONFIG_DELETE', actor, {
auditConfigId: id,
before: { targetEndpoint: removed.targetEndpoint, enabled: removed.enabled },
after: null,
});

res.status(204).end();
} catch (error) {
next(error);
}
});

return router;
}

export default createAuditRouter();
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { createForecastRouter } from "./forecast.js";
import { createErrorsRouter } from "./errors.js";
import { config } from "../config/index.js";
import { createBillingRateLimitMiddleware } from "../middleware/rateLimit.js";
import { createAuditRouter } from "./audit.js";
import type { AuditService } from "../services/auditService.js";

const openApiPath = path.join(process.cwd(), "docs/openapi.json");
Expand All @@ -54,6 +55,7 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router {
router.use("/health", healthRouter);
router.use("/spike", createSpikeRouter());
router.use("/errors", createErrorsRouter({ auditService: deps.auditService }));
router.use("/audit", createAuditRouter({ auditService: deps.auditService }));

router.use(
"/apis",
Expand Down
Loading