diff --git a/migrations/20260601000000_create_idempotency_keys.sql b/migrations/20260601000000_create_idempotency_keys.sql new file mode 100644 index 00000000..5ceddbc8 --- /dev/null +++ b/migrations/20260601000000_create_idempotency_keys.sql @@ -0,0 +1,32 @@ +-- Create idempotency_keys table for safe retry of funding operations +-- Migration: 20260601000000_create_idempotency_keys.sql + +CREATE TABLE IF NOT EXISTS idempotency_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + idempotency_key VARCHAR(128) NOT NULL, + request_fingerprint VARCHAR(64) NOT NULL, + response_status INTEGER, + response_body JSONB, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +-- Index for fast key lookups +CREATE UNIQUE INDEX IF NOT EXISTS idx_idempotency_keys_key + ON idempotency_keys (idempotency_key); + +-- Index for cleanup of expired keys +CREATE INDEX IF NOT EXISTS idx_idempotency_keys_expires_at + ON idempotency_keys (expires_at); + +-- Auto-update updated_at on row change +CREATE TRIGGER update_idempotency_keys_updated_at + BEFORE UPDATE ON idempotency_keys + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +COMMENT ON TABLE idempotency_keys IS + 'Stores idempotency key → response mappings for funding submissions. Keys expire after TTL.'; +COMMENT ON COLUMN idempotency_keys.request_fingerprint IS + 'SHA-256 hash of the request body for conflict detection'; diff --git a/src/middleware/idempotency.js b/src/middleware/idempotency.js new file mode 100644 index 00000000..fc0ad6ca --- /dev/null +++ b/src/middleware/idempotency.js @@ -0,0 +1,155 @@ +'use strict'; + +/** + * Idempotency middleware for POST /api/invest/fund-invoice and escrow + * funding submissions. + * + * Accepts an `Idempotency-Key` header validated against the existing + * IDEMPOTENCY_KEY_PATTERN from escrowSubmit.js. Stores key ? + * (request fingerprint, status, response) with a TTL in a new + * `idempotency_keys` table. Returns the cached response on duplicate + * keys; returns 409 when the same key is reused with a different request + * body fingerprint. + * + * Security: + * - Keys are validated against a strict pattern before any DB access. + * - Request body is hashed (SHA-256) before storage — no raw payload + * is persisted. + * - Keys expire after a configurable TTL (default 24 h) and are + * automatically purged. + */ + +const crypto = require('crypto'); +const { IDEMPOTENCY_KEY_PATTERN } = require('../services/escrowSubmit'); +const db = require('../db/knex'); + +const DEFAULT_TTL_HOURS = 24; + +/** + * Get TTL in hours from env or default. + * @returns {number} + */ +function getTTLHours() { + const raw = process.env.IDEMPOTENCY_KEY_TTL_HOURS; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_HOURS; +} + +/** + * Compute a SHA-256 fingerprint of the request body for conflict detection. + * @param {object} body + * @returns {string} + */ +function fingerprint(body) { + return crypto + .createHash('sha256') + .update(JSON.stringify(body), 'utf8') + .digest('hex'); +} + +/** + * Express middleware that enforces idempotency on funding submissions. + * + * 1. Rejects missing / invalid `Idempotency-Key` header ? 400 + * 2. Looks up the key in the database + * a. Found + same fingerprint ? returns cached response (200/201) + * b. Found + different fingerprint ? 409 Conflict + * c. Not found ? stores the key + fingerprint, continues + * 3. On response finish, stores the status + body for future replays + */ +/** + * Express middleware enforcing idempotency on funding submissions. + * @param {object} req - Express request + * @param {object} res - Express response + * @param {function} next - Express next callback + * @returns {void} + */ +function idempotencyMiddleware(req, res, next) { + const key = req.header('Idempotency-Key'); + if (!key) { + return res.status(400).json({ + success: false, + error: 'Idempotency-Key header is required for this endpoint.', + }); + } + + if (!IDEMPOTENCY_KEY_PATTERN.test(key)) { + return res.status(400).json({ + success: false, + error: + 'Idempotency-Key must be 8–128 URL-safe characters (A-Za-z0-9._:-).', + }); + } + + const bodyFingerprint = fingerprint(req.body); + const ttlHours = getTTLHours(); + + // Use a transaction so we don't race on insert + db.transaction(async (trx) => { + const existing = await trx('idempotency_keys') + .where({ idempotency_key: key }) + .first(); + + if (existing) { + // Same key — check fingerprint + if (existing.request_fingerprint !== bodyFingerprint) { + return res.status(409).json({ + success: false, + error: + 'Idempotency-Key reused with a different request body. Use a unique key for each distinct payload.', + }); + } + + // Replay — return the original cached response + const cached = existing.response_body; + const status = existing.response_status || 201; + try { + const parsed = typeof cached === 'string' ? JSON.parse(cached) : cached; + return res.status(status).json(parsed); + } catch { + return res.status(status).json(cached); + } + } + + // New key — insert placeholder + await trx('idempotency_keys').insert({ + idempotency_key: key, + request_fingerprint: bodyFingerprint, + response_status: null, + response_body: null, + expires_at: db.raw("NOW() + INTERVAL '?? hours'", [ttlHours]), + }); + + // Override res.json to capture the response before sending + const originalJson = res.json.bind(res); + res.json = function (body) { + // Store the response for future replays (fire-and-forget) + trx('idempotency_keys') + .where({ idempotency_key: key }) + .update({ + response_status: res.statusCode, + response_body: JSON.stringify(body), + updated_at: db.fn.now(), + }) + .catch(() => { + // Best-effort — don't fail the request if storage fails + }); + + return originalJson(body); + }; + + next(); + }).catch((err) => { + // Transaction-level errors (e.g. DB down) + if (!res.headersSent) { + return res.status(500).json({ + success: false, + error: 'Internal server error processing idempotency key.', + }); + } + // If headers already sent, the error happened post-response — log only + console.error('[idempotency] Post-response storage error:', err.message); + }); +} + +module.exports = idempotencyMiddleware; diff --git a/src/routes/invest.js b/src/routes/invest.js index 2117e3db..51226c36 100644 --- a/src/routes/invest.js +++ b/src/routes/invest.js @@ -7,6 +7,7 @@ */ const express = require('express'); +const idempotencyMiddleware = require('../middleware/idempotency'); const router = express.Router(); const investService = require('../services/investService'); const { authenticateToken } = require('../middleware/auth'); @@ -172,6 +173,7 @@ router.get('/opportunities', async (req, res, next) => { */ router.post( '/fund-invoice', + idempotencyMiddleware, requireKycForFunding, async (req, res, next) => { try { diff --git a/tests/idempotency.test.js b/tests/idempotency.test.js new file mode 100644 index 00000000..53f7c481 --- /dev/null +++ b/tests/idempotency.test.js @@ -0,0 +1,234 @@ +'use strict'; + +/** + * Tests for the idempotency middleware covering: + * - Missing Idempotency-Key header ? 400 + * - Invalid key format ? 400 + * - First call executes normally ? 201 + * - Duplicate key replays original response ? 201 + * - Same key + different body ? 409 + * - Keys persist in the database + */ + +const request = require('supertest'); +const express = require('express'); +const crypto = require('crypto'); + +// -- Helpers --------------------------------------------------------------- + +/** Generate a valid idempotency key */ +function validKey() { + return 'ik_' + crypto.randomBytes(8).toString('hex'); +} + +/** Minimal valid funding request body */ +function validBody(overrides = {}) { + return { + invoiceId: 'INV-2024-001', + investmentAmount: 5000.00, + smeId: 'SME-789', + ...overrides, + }; +} + +// -- Setup ----------------------------------------------------------------- + +// We need to mock the knex db module BEFORE requiring the middleware. +// The middleware requires db/knex at module load time. +jest.mock('../src/db/knex', () => { + const store = new Map(); + return { + transaction: jest.fn((fn) => { + const trx = { + __store: store, + where: jest.fn().mockReturnThis(), + first: jest.fn(async () => { + // Find by idempotency_key + return trx._lastKey ? store.get(trx._lastKey) || null : null; + }), + insert: jest.fn(async (row) => { + trx._lastKey = row.idempotency_key; + store.set(row.idempotency_key, { + ...row, + created_at: new Date(), + updated_at: new Date(), + }); + }), + update: jest.fn(async (updates) => { + if (trx._lastKey) { + const existing = store.get(trx._lastKey) || {}; + store.set(trx._lastKey, { ...existing, ...updates }); + } + }), + _lastKey: null, + raw: jest.fn(() => new Date(Date.now() + 86400000)), + fn: { now: () => new Date() }, + }; + return fn(trx); + }), + fn: { now: () => new Date() }, + raw: jest.fn(() => new Date(Date.now() + 86400000)), + }; +}); + +// Now we can require the middleware +const idempotencyMiddleware = require('../middleware/idempotency'); + +function createApp() { + const app = express(); + app.use(express.json()); + app.post('/api/invest/fund-invoice', idempotencyMiddleware, (req, res) => { + return res.status(201).json({ + data: { + investmentId: 'inv_test_' + Date.now(), + invoiceId: req.body.invoiceId, + smeId: req.body.smeId, + investmentAmount: req.body.investmentAmount, + status: 'pending', + }, + meta: { timestamp: new Date().toISOString() }, + message: 'Investment submitted successfully.', + }); + }); + return app; +} + +// -- Tests ----------------------------------------------------------------- + +describe('Idempotency Middleware', () => { + let app; + + beforeEach(() => { + app = createApp(); + }); + + // -- Validation -------------------------------------------------------- + + it('returns 400 when Idempotency-Key header is missing', async () => { + const res = await request(app) + .post('/api/invest/fund-invoice') + .send(validBody()) + .expect(400); + + expect(res.body.error).toMatch(/Idempotency-Key header is required/); + }); + + it('returns 400 when Idempotency-Key contains invalid characters', async () => { + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', 'invalid key with spaces!') + .send(validBody()) + .expect(400); + + expect(res.body.error).toMatch(/8.*128.*URL-safe/); + }); + + it('returns 400 when Idempotency-Key is too short', async () => { + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', 'short') + .send(validBody()) + .expect(400); + + expect(res.body.error).toMatch(/8.*128.*URL-safe/); + }); + + // -- First call --------------------------------------------------------- + + it('executes the handler on first call (new key)', async () => { + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', validKey()) + .send(validBody()) + .expect(201); + + expect(res.body.data.status).toBe('pending'); + expect(res.body.data.investmentId).toBeDefined(); + }); + + // -- Duplicate key replay ----------------------------------------------- + + it('returns the cached response on duplicate key with same body', async () => { + const key = validKey(); + const body = validBody(); + + // First call + const first = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send(body) + .expect(201); + + // Second call with same key and body + const second = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send(body) + .expect(201); + + // Should return the same investmentId + expect(second.body.data.investmentId).toBe(first.body.data.investmentId); + expect(second.body.data.status).toBe('pending'); + }); + + // -- Conflicting body --------------------------------------------------- + + it('returns 409 when same key is used with a different body', async () => { + const key = validKey(); + + // First call with body A + await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send(validBody({ investmentAmount: 1000 })) + .expect(201); + + // Second call with same key but body B + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send(validBody({ investmentAmount: 2000 })) + .expect(409); + + expect(res.body.error).toMatch(/different request body/); + }); + + // -- Multiple different keys -------------------------------------------- + + it('allows multiple requests with different keys', async () => { + const key1 = validKey(); + const key2 = validKey(); + + const res1 = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key1) + .send(validBody({ invoiceId: 'INV-001' })) + .expect(201); + + const res2 = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key2) + .send(validBody({ invoiceId: 'INV-002' })) + .expect(201); + + // Different keys should produce different investmentIds + expect(res1.body.data.investmentId).not.toBe(res2.body.data.investmentId); + expect(res1.body.data.invoiceId).toBe('INV-001'); + expect(res2.body.data.invoiceId).toBe('INV-002'); + }); + + // -- Empty body handling ------------------------------------------------ + + it('handles requests with empty body', async () => { + const key = validKey(); + + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send({}) + .expect(201); + + // The handler should still return a response even with empty body + expect(res.body.data.investmentId).toBeDefined(); + }); +});