From 7683ad1f33f1c455497a8c8fbbf3e1ecec6a0bf8 Mon Sep 17 00:00:00 2001 From: Ayilojay Date: Sat, 30 May 2026 11:04:05 +0000 Subject: [PATCH] feat(audit): expose invoice audit trail export api - Add GET /api/admin/audit/invoices/:invoiceId (paginated trail) - Add GET /api/admin/audit/invoices/:invoiceId/transitions (state history) - Add GET /api/admin/audit/invoices/:invoiceId/export (JSON/CSV) - Gate all routes with adminAuth (JWT or x-api-key) + tenant isolation - Extend auditLog service with exportInvoiceAuditLogs and tenant scoping - 22 tests covering auth, pagination, CSV escaping, secret redaction - Document endpoints in docs/compliance.md Closes #208 --- docs/compliance.md | 125 +++++++++++ src/app.js | 2 + src/routes/auditTrail.js | 153 ++++++++++++++ src/services/auditLog.js | 62 +++++- tests/auditTrail.api.test.js | 397 +++++++++++++++++++++++++++++++++++ 5 files changed, 737 insertions(+), 2 deletions(-) create mode 100644 src/routes/auditTrail.js create mode 100644 tests/auditTrail.api.test.js diff --git a/docs/compliance.md b/docs/compliance.md index 8f84bb06..3a772bcb 100644 --- a/docs/compliance.md +++ b/docs/compliance.md @@ -620,3 +620,128 @@ Before production deployment: **Last Updated**: May 28, 2026 **Maintained By**: LiquiFact Backend Team **Related Issues**: #222 — Enforce KYC gating on all capital-movement endpoints + +--- + +## Invoice Audit Trail & State-Transition History API + +**Status**: Implemented +**Date**: May 2026 +**Relates to**: Issue #208 — Add admin endpoint for invoice audit trail and state-transition history export + +### Overview + +Compliance operators can retrieve and export the full audit history for any invoice, including all mutations and state transitions. All endpoints are admin-gated and tenant-isolated. + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/admin/audit/invoices/:invoiceId` | Paginated audit trail | +| GET | `/api/admin/audit/invoices/:invoiceId/transitions` | State-transition history | +| GET | `/api/admin/audit/invoices/:invoiceId/export` | Export as JSON or CSV | + +### Authentication + +All endpoints accept either: +- `Authorization: Bearer ` — admin JWT with `tenantId` claim +- `X-API-KEY: ` — service-to-service API key + +Tenant context is resolved from the `x-tenant-id` header (highest priority) or the `tenantId` JWT claim. Requests without a resolvable tenant are rejected with `400`. + +### Tenant Isolation + +Every query is scoped to the authenticated operator's tenant. An operator cannot retrieve audit records belonging to another tenant. + +### Pagination + +Query params: `limit` (1–500, default 50) and `offset` (default 0). + +```bash +GET /api/admin/audit/invoices/inv-001?limit=20&offset=40 +``` + +Response includes a `meta` object: +```json +{ + "data": [...], + "meta": { "invoiceId": "inv-001", "limit": 20, "offset": 40, "total": 87 } +} +``` + +### Export Formats + +#### JSON (default) + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + -H "x-tenant-id: tenant-alpha" \ + "http://localhost:3001/api/admin/audit/invoices/inv-001/export" +``` + +Returns `application/json` — a JSON array of audit log entries. + +#### CSV + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + -H "x-tenant-id: tenant-alpha" \ + "http://localhost:3001/api/admin/audit/invoices/inv-001/export?format=csv" \ + -o audit-inv-001.csv +``` + +Returns `text/csv` with `Content-Disposition: attachment`. CSV columns: + +``` +id,timestamp,actor,action,resourceType,resourceId,statusCode,ipAddress,userAgent +``` + +Fields containing commas, double-quotes, or newlines are RFC 4180-escaped (wrapped in double-quotes, internal quotes doubled). + +### Secret Redaction + +Sensitive fields (`password`, `token`, `secret`, `apiKey`, `privateKey`, etc.) are redacted to `***REDACTED***` before any log entry is stored or exported. This is enforced at write time by `sanitizeSensitiveData` in `src/services/auditLog.js` and `redactValue` in `src/services/auditLogStore.js`. + +### State-Transition History + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + -H "x-tenant-id: tenant-alpha" \ + "http://localhost:3001/api/admin/audit/invoices/inv-001/transitions" +``` + +Response: +```json +{ + "data": [ + { + "id": "AUDIT-...", + "timestamp": "2026-05-30T10:00:00.000Z", + "actor": "admin-1", + "fromState": "pending", + "toState": "approved", + "reason": null, + "ipAddress": "127.0.0.1" + } + ], + "meta": { "invoiceId": "inv-001" } +} +``` + +### Security Notes + +- Endpoints are read-only; no mutations are possible through this API. +- Input validation rejects `invoiceId` values longer than 128 characters. +- Pagination bounds are clamped server-side (max 500 per page). +- All responses omit internal stack traces and infrastructure details. +- The audit log store is append-only at the database layer (see `migrations/202604260002_enforce_audit_log_append_only.sql`). + +### Deployment Checklist + +- [ ] Ensure `JWT_SECRET` is set in deployment secrets +- [ ] Confirm `x-tenant-id` header is forwarded by API gateway / load balancer +- [ ] Verify audit log DB migrations have run (`npm run db:migrate`) +- [ ] Run tests: `npx jest tests/auditTrail.api.test.js` + +**Last Updated**: May 30, 2026 +**Relates to**: Issue #208 diff --git a/src/app.js b/src/app.js index 0cac55dd..da3d1309 100644 --- a/src/app.js +++ b/src/app.js @@ -37,6 +37,7 @@ const logger = require('./logger'); const { metricsAuth, metricsHandler } = require('./metrics'); const smeRoutes = require('./routes/sme'); const invoiceFileRoutes = require('./routes/invoiceFile'); +const auditTrailRoutes = require('./routes/auditTrail'); /** * Returns a 403 JSON response only for the dedicated blocked-origin CORS error. @@ -248,6 +249,7 @@ function createApp() { // ── 5. SME & Invoice File routes ───────────────────────────────────────── app.use('/api/sme', smeRoutes); app.use('/api/invoices', invoiceFileRoutes); + app.use('/api/admin/audit', auditTrailRoutes); // ── 6. Prometheus metrics ──────────────────────────────────────────────── app.get('/metrics', metricsAuth, metricsHandler); diff --git a/src/routes/auditTrail.js b/src/routes/auditTrail.js new file mode 100644 index 00000000..1681fdfb --- /dev/null +++ b/src/routes/auditTrail.js @@ -0,0 +1,153 @@ +'use strict'; + +/** + * @fileoverview Admin routes for invoice audit trail and state-transition history export. + * All routes require admin authentication (JWT or API key) and tenant isolation. + * + * Routes: + * GET /api/admin/audit/invoices/:invoiceId - Paginated audit trail + * GET /api/admin/audit/invoices/:invoiceId/transitions - State-transition history + * GET /api/admin/audit/invoices/:invoiceId/export - Export as JSON or CSV + * + * @module routes/auditTrail + */ + +const express = require('express'); +const router = express.Router(); +const { authenticateToken } = require('../middleware/auth'); +const { apiKeyAuth } = require('../middleware/apiKey'); +const { extractTenant } = require('../middleware/tenant'); +const { getInvoiceAuditTrail, countAuditLogs, exportInvoiceAuditLogs, getAuditLogs } = require('../services/auditLog'); +const { getTransitionHistory } = require('../services/invoiceStateMachine'); +const AppError = require('../errors/AppError'); + +const MAX_LIMIT = 500; +const DEFAULT_LIMIT = 50; + +/** + * Accepts either a valid admin JWT or a valid API key. + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +function adminAuth(req, res, next) { + if (req.headers['x-api-key']) { + return apiKeyAuth(req, res, next); + } + return authenticateToken(req, res, next); +} + +/** + * Parse and clamp pagination params from query string. + * @param {object} query + * @returns {{ limit: number, offset: number }} + */ +function parsePagination(query) { + const limit = Math.min(Math.max(parseInt(query.limit, 10) || DEFAULT_LIMIT, 1), MAX_LIMIT); + const offset = Math.max(parseInt(query.offset, 10) || 0, 0); + return { limit, offset }; +} + +/** + * Validate invoiceId path param — reject obviously malformed values. + * @param {string} invoiceId + * @returns {boolean} + */ +function isValidInvoiceId(invoiceId) { + return typeof invoiceId === 'string' && invoiceId.length > 0 && invoiceId.length <= 128; +} + +// ── Middleware stack for all routes ────────────────────────────────────────── +router.use(adminAuth, extractTenant); + +/** + * GET /api/admin/audit/invoices/:invoiceId + * Returns paginated audit trail for a specific invoice. + * Tenant-scoped: only returns records matching req.tenantId. + */ +router.get('/invoices/:invoiceId', (req, res, next) => { + try { + const { invoiceId } = req.params; + if (!isValidInvoiceId(invoiceId)) { + return next(new AppError({ + type: 'https://liquifact.com/probs/validation-error', + title: 'Validation Error', + status: 400, + detail: 'Invalid invoiceId.', + })); + } + + const { limit, offset } = parsePagination(req.query); + const logs = getInvoiceAuditTrail(invoiceId, limit, offset, req.tenantId); + const total = countAuditLogs({ resourceId: invoiceId, resourceType: 'invoice', tenantId: req.tenantId }); + + return res.json({ + data: logs, + meta: { invoiceId, limit, offset, total }, + }); + } catch (err) { + return next(err); + } +}); + +/** + * GET /api/admin/audit/invoices/:invoiceId/transitions + * Returns state-transition history for a specific invoice. + */ +router.get('/invoices/:invoiceId/transitions', (req, res, next) => { + try { + const { invoiceId } = req.params; + if (!isValidInvoiceId(invoiceId)) { + return next(new AppError({ + type: 'https://liquifact.com/probs/validation-error', + title: 'Validation Error', + status: 400, + detail: 'Invalid invoiceId.', + })); + } + + const transitions = getTransitionHistory(invoiceId, (opts) => + getAuditLogs({ ...opts, tenantId: req.tenantId }) + ); + + return res.json({ data: transitions, meta: { invoiceId } }); + } catch (err) { + return next(err); + } +}); + +/** + * GET /api/admin/audit/invoices/:invoiceId/export + * Exports audit trail as JSON or CSV. + * Query params: format=json|csv, limit, offset + */ +router.get('/invoices/:invoiceId/export', (req, res, next) => { + try { + const { invoiceId } = req.params; + if (!isValidInvoiceId(invoiceId)) { + return next(new AppError({ + type: 'https://liquifact.com/probs/validation-error', + title: 'Validation Error', + status: 400, + detail: 'Invalid invoiceId.', + })); + } + + const format = req.query.format === 'csv' ? 'csv' : 'json'; + const { limit } = parsePagination(req.query); + const output = exportInvoiceAuditLogs({ invoiceId, limit, format, tenantId: req.tenantId }); + + if (format === 'csv') { + res.set('Content-Type', 'text/csv'); + res.set('Content-Disposition', `attachment; filename="audit-${invoiceId}.csv"`); + return res.send(output); + } + + res.set('Content-Type', 'application/json'); + return res.send(output); + } catch (err) { + return next(err); + } +}); + +module.exports = router; diff --git a/src/services/auditLog.js b/src/services/auditLog.js index 2a7ccd00..0bbe3c0c 100644 --- a/src/services/auditLog.js +++ b/src/services/auditLog.js @@ -175,6 +175,7 @@ function createAuditLog({ * @param {string} [options.resourceType] Filter by resource type * @param {string} [options.actor] Filter by actor * @param {string} [options.action] Filter by action + * @param {string} [options.tenantId] Filter by tenant ID for isolation * @param {number} [options.limit=100] Maximum number of records to return * @param {number} [options.offset=0] Number of records to skip * @returns {Array} Matching audit log entries (read-only copies) @@ -184,6 +185,7 @@ function getAuditLogs({ resourceType = null, actor = null, action = null, + tenantId = null, limit = 100, offset = 0, } = {}) { @@ -201,6 +203,9 @@ function getAuditLogs({ if (action) { filtered = filtered.filter((log) => log.action === action); } + if (tenantId) { + filtered = filtered.filter((log) => log.metadata && log.metadata.tenantId === tenantId); + } // Return in reverse chronological order (newest first) return filtered @@ -216,13 +221,17 @@ function getAuditLogs({ * * @param {string} invoiceId Invoice resource ID * @param {number} [limit=100] Maximum records to return + * @param {number} [offset=0] Records to skip (for pagination) + * @param {string} [tenantId] Tenant ID for isolation * @returns {Array} Audit log entries for the invoice */ -function getInvoiceAuditTrail(invoiceId, limit = 100) { +function getInvoiceAuditTrail(invoiceId, limit = 100, offset = 0, tenantId = null) { return getAuditLogs({ resourceId: invoiceId, resourceType: 'invoice', limit, + offset, + tenantId, }); } @@ -234,7 +243,7 @@ function getInvoiceAuditTrail(invoiceId, limit = 100) { * @returns {number} Total count of matching entries */ function countAuditLogs(options = {}) { - const logs = getAuditLogs({ ...options, limit: Infinity }); + const logs = getAuditLogs({ ...options, limit: Infinity, offset: 0 }); return logs.length; } @@ -292,6 +301,54 @@ function exportAuditLogs({ limit = Infinity, format = 'json' } = {}) { return JSON.stringify(logs, null, 2); } +/** + * Exports audit logs for a specific invoice as JSON or CSV. + * Secrets are redacted via sanitizeSensitiveData. + * + * @param {Object} options + * @param {string} options.invoiceId Invoice resource ID + * @param {number} [options.limit=100] Maximum records to export + * @param {string} [options.format='json'] 'json' or 'csv' + * @param {string} [options.tenantId] Tenant ID for isolation + * @returns {string} Formatted audit log output + */ +function exportInvoiceAuditLogs({ invoiceId, limit = 100, format = 'json', tenantId = null } = {}) { + const logs = getAuditLogs({ + resourceId: invoiceId, + resourceType: 'invoice', + limit, + offset: 0, + tenantId, + }); + + if (format === 'csv') { + const escapeCsv = (val) => { + const str = val == null ? '' : String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + const headers = 'id,timestamp,actor,action,resourceType,resourceId,statusCode,ipAddress,userAgent'; + const rows = logs.map((log) => + [ + escapeCsv(log.id), + escapeCsv(log.timestamp), + escapeCsv(log.actor), + escapeCsv(log.action), + escapeCsv(log.resourceType), + escapeCsv(log.resourceId), + log.statusCode, + escapeCsv(log.ipAddress), + escapeCsv(log.userAgent), + ].join(',') + ); + return rows.length > 0 ? `${headers}\n${rows.join('\n')}` : headers; + } + + return JSON.stringify(logs, null, 2); +} + module.exports = { createAuditLog, getAuditLogs, @@ -299,6 +356,7 @@ module.exports = { countAuditLogs, clearAuditLogs, exportAuditLogs, + exportInvoiceAuditLogs, // Exported for testing purposes generateAuditLogId, sanitizeSensitiveData, diff --git a/tests/auditTrail.api.test.js b/tests/auditTrail.api.test.js new file mode 100644 index 00000000..82a4f802 --- /dev/null +++ b/tests/auditTrail.api.test.js @@ -0,0 +1,397 @@ +'use strict'; + +/** + * @fileoverview API tests for the invoice audit trail endpoints. + * Covers: trail retrieval, CSV export with escaping, pagination, authz rejection, + * tenant isolation, and state-transition history. + */ + +jest.mock('../src/db/knex'); +jest.mock('../src/middleware/apiKey', () => ({ + apiKeyAuth: jest.fn((req, res, next) => { + req.apiClient = { clientId: 'api-client-1' }; + next(); + }), +})); + +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); + +const { + createAuditLog, + clearAuditLogs, + getAuditLogs, +} = require('../src/services/auditLog'); +const { executeTransition } = require('../src/services/invoiceStateMachine'); +const auditTrailRouter = require('../src/routes/auditTrail'); + +const JWT_SECRET = process.env.JWT_SECRET || 'test-secret'; +const TENANT_A = 'tenant-alpha'; +const TENANT_B = 'tenant-beta'; + +/** Build a signed JWT for a given tenantId */ +function makeToken(tenantId = TENANT_A) { + return jwt.sign({ sub: 'admin-1', tenantId }, JWT_SECRET, { expiresIn: '1h' }); +} + +/** Build a minimal Express app mounting the audit trail router */ +function buildApp() { + const app = express(); + app.use(express.json()); + app.use('/api/admin/audit', auditTrailRouter); + app.use((err, req, res, _next) => { + res.status(err.status || 500).json({ error: err.detail || err.message || 'error' }); + }); + return app; +} + +/** Seed an audit log entry for a given invoiceId and optional tenantId */ +function seedLog(invoiceId, tenantId = TENANT_A, overrides = {}) { + return createAuditLog({ + actor: 'admin-1', + action: 'UPDATE', + resourceType: 'invoice', + resourceId: invoiceId, + statusCode: 200, + metadata: { tenantId, ...overrides.metadata }, + ...overrides, + }); +} + +describe('GET /api/admin/audit/invoices/:invoiceId', () => { + let app; + + beforeEach(() => { + clearAuditLogs(); + app = buildApp(); + }); + + it('returns 401 when no auth is provided', async () => { + const res = await request(app) + .get('/api/admin/audit/invoices/inv-001') + .set('x-tenant-id', TENANT_A); + expect(res.status).toBe(401); + }); + + it('returns 400 when tenant context is missing', async () => { + const res = await request(app) + .get('/api/admin/audit/invoices/inv-001') + .set('Authorization', `Bearer ${makeToken()}`); + // extractTenant rejects with 400 when no tenant header and no JWT claim + // Our token includes tenantId so this should pass — test without tenantId in token + const tokenNoTenant = jwt.sign({ sub: 'admin-1' }, JWT_SECRET, { expiresIn: '1h' }); + const res2 = await request(app) + .get('/api/admin/audit/invoices/inv-001') + .set('Authorization', `Bearer ${tokenNoTenant}`); + expect(res2.status).toBe(400); + }); + + it('returns empty data array when no logs exist for invoice', async () => { + const res = await request(app) + .get('/api/admin/audit/invoices/inv-none') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + expect(res.body.meta.total).toBe(0); + }); + + it('returns audit trail for a specific invoice', async () => { + seedLog('inv-001'); + seedLog('inv-001'); + seedLog('inv-002'); // different invoice — should not appear + + const res = await request(app) + .get('/api/admin/audit/invoices/inv-001') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.meta.invoiceId).toBe('inv-001'); + expect(res.body.meta.total).toBe(2); + res.body.data.forEach((entry) => expect(entry.resourceId).toBe('inv-001')); + }); + + it('enforces tenant isolation — does not return other tenant logs', async () => { + seedLog('inv-001', TENANT_A); + seedLog('inv-001', TENANT_B); // different tenant + + const res = await request(app) + .get('/api/admin/audit/invoices/inv-001') + .set('Authorization', `Bearer ${makeToken(TENANT_A)}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.meta.total).toBe(1); + }); + + it('supports pagination via limit and offset', async () => { + for (let i = 0; i < 5; i++) seedLog('inv-page'); + + const page1 = await request(app) + .get('/api/admin/audit/invoices/inv-page?limit=2&offset=0') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + const page2 = await request(app) + .get('/api/admin/audit/invoices/inv-page?limit=2&offset=2') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(page1.status).toBe(200); + expect(page1.body.data).toHaveLength(2); + expect(page1.body.meta.limit).toBe(2); + expect(page1.body.meta.offset).toBe(0); + + expect(page2.status).toBe(200); + expect(page2.body.data).toHaveLength(2); + expect(page2.body.meta.offset).toBe(2); + + // IDs should be different across pages + const ids1 = page1.body.data.map((e) => e.id); + const ids2 = page2.body.data.map((e) => e.id); + expect(ids1.some((id) => ids2.includes(id))).toBe(false); + }); + + it('clamps limit to MAX_LIMIT (500)', async () => { + seedLog('inv-clamp'); + const res = await request(app) + .get('/api/admin/audit/invoices/inv-clamp?limit=9999') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + expect(res.status).toBe(200); + expect(res.body.meta.limit).toBe(500); + }); + + it('returns 400 for an empty invoiceId', async () => { + // Express won't match empty segment — test a very long id instead + const longId = 'x'.repeat(129); + const res = await request(app) + .get(`/api/admin/audit/invoices/${longId}`) + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + expect(res.status).toBe(400); + }); + + it('accepts x-api-key auth', async () => { + seedLog('inv-apikey'); + const res = await request(app) + .get('/api/admin/audit/invoices/inv-apikey') + .set('x-api-key', 'any-key') + .set('x-tenant-id', TENANT_A); + expect(res.status).toBe(200); + }); +}); + +describe('GET /api/admin/audit/invoices/:invoiceId/transitions', () => { + let app; + + beforeEach(() => { + clearAuditLogs(); + app = buildApp(); + }); + + it('returns 401 without auth', async () => { + const res = await request(app) + .get('/api/admin/audit/invoices/inv-001/transitions') + .set('x-tenant-id', TENANT_A); + expect(res.status).toBe(401); + }); + + it('returns empty array when no transitions exist', async () => { + const res = await request(app) + .get('/api/admin/audit/invoices/inv-notrans/transitions') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + }); + + it('returns state-transition history for an invoice', async () => { + // Seed a STATE_TRANSITION log directly + createAuditLog({ + actor: 'admin-1', + action: 'STATE_TRANSITION', + resourceType: 'invoice', + resourceId: 'inv-trans', + before: { state: 'pending' }, + after: { state: 'approved' }, + metadata: { tenantId: TENANT_A }, + }); + + const res = await request(app) + .get('/api/admin/audit/invoices/inv-trans/transitions') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + const t = res.body.data[0]; + expect(t.fromState).toBe('pending'); + expect(t.toState).toBe('approved'); + expect(t.actor).toBe('admin-1'); + }); + + it('enforces tenant isolation on transitions', async () => { + createAuditLog({ + actor: 'admin-1', + action: 'STATE_TRANSITION', + resourceType: 'invoice', + resourceId: 'inv-iso', + before: { state: 'pending' }, + after: { state: 'approved' }, + metadata: { tenantId: TENANT_B }, // different tenant + }); + + const res = await request(app) + .get('/api/admin/audit/invoices/inv-iso/transitions') + .set('Authorization', `Bearer ${makeToken(TENANT_A)}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + }); +}); + +describe('GET /api/admin/audit/invoices/:invoiceId/export', () => { + let app; + + beforeEach(() => { + clearAuditLogs(); + app = buildApp(); + }); + + it('returns 401 without auth', async () => { + const res = await request(app) + .get('/api/admin/audit/invoices/inv-001/export') + .set('x-tenant-id', TENANT_A); + expect(res.status).toBe(401); + }); + + it('exports JSON by default', async () => { + seedLog('inv-export'); + const res = await request(app) + .get('/api/admin/audit/invoices/inv-export/export') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + const parsed = JSON.parse(res.text); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].resourceId).toBe('inv-export'); + }); + + it('exports CSV with correct headers', async () => { + seedLog('inv-csv'); + const res = await request(app) + .get('/api/admin/audit/invoices/inv-csv/export?format=csv') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/csv/); + expect(res.headers['content-disposition']).toMatch(/attachment/); + const lines = res.text.split('\n'); + expect(lines[0]).toBe('id,timestamp,actor,action,resourceType,resourceId,statusCode,ipAddress,userAgent'); + expect(lines.length).toBeGreaterThan(1); + }); + + it('CSV escapes commas in field values', async () => { + createAuditLog({ + actor: 'admin,with,commas', + action: 'UPDATE', + resourceType: 'invoice', + resourceId: 'inv-escape', + metadata: { tenantId: TENANT_A }, + }); + + const res = await request(app) + .get('/api/admin/audit/invoices/inv-escape/export?format=csv') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + expect(res.text).toContain('"admin,with,commas"'); + }); + + it('CSV escapes double-quotes in field values', async () => { + createAuditLog({ + actor: 'admin"quoted"', + action: 'UPDATE', + resourceType: 'invoice', + resourceId: 'inv-quote', + metadata: { tenantId: TENANT_A }, + }); + + const res = await request(app) + .get('/api/admin/audit/invoices/inv-quote/export?format=csv') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + // Double-quote escaping: " → "" + expect(res.text).toContain('"admin""quoted"""'); + }); + + it('CSV returns only header row when no logs exist', async () => { + const res = await request(app) + .get('/api/admin/audit/invoices/inv-empty/export?format=csv') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + expect(res.text.trim()).toBe('id,timestamp,actor,action,resourceType,resourceId,statusCode,ipAddress,userAgent'); + }); + + it('does not expose sensitive fields in export', async () => { + createAuditLog({ + actor: 'admin-1', + action: 'UPDATE', + resourceType: 'invoice', + resourceId: 'inv-redact', + before: { apiKey: 'super-secret-before', amount: 100 }, + after: { apiKey: 'super-secret-after', amount: 200 }, + metadata: { tenantId: TENANT_A }, + }); + + const res = await request(app) + .get('/api/admin/audit/invoices/inv-redact/export') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + expect(res.text).not.toContain('super-secret-before'); + expect(res.text).not.toContain('super-secret-after'); + expect(res.text).toContain('***REDACTED***'); + }); + + it('enforces tenant isolation on export', async () => { + seedLog('inv-tenant-export', TENANT_B); + + const res = await request(app) + .get('/api/admin/audit/invoices/inv-tenant-export/export') + .set('Authorization', `Bearer ${makeToken(TENANT_A)}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + const parsed = JSON.parse(res.text); + expect(parsed).toHaveLength(0); + }); + + it('respects limit param on export', async () => { + for (let i = 0; i < 10; i++) seedLog('inv-limit-export'); + + const res = await request(app) + .get('/api/admin/audit/invoices/inv-limit-export/export?limit=3') + .set('Authorization', `Bearer ${makeToken()}`) + .set('x-tenant-id', TENANT_A); + + expect(res.status).toBe(200); + const parsed = JSON.parse(res.text); + expect(parsed).toHaveLength(3); + }); +});