From 173b703d1e331f999d8251e2a1bb424024f9239a Mon Sep 17 00:00:00 2001 From: Mrwicks00 Date: Thu, 28 May 2026 02:00:21 +0100 Subject: [PATCH 1/2] fix(kyc): enforce gating across all capital-movement routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Closes #222: requireKycForFunding was only wired to POST /api/invest/fund-invoice. Any other funding/settlement route could transfer capital without a KYC check. Changes: src/middleware/kycGating.js - FIX: smeId is now resolved ONLY from req.user.smeId (the JWT principal). Previously the fallback chain req.user.smeId || req.body?.smeId || req.params?.smeId allowed an authenticated caller to supply a verified SME's ID they do not own and bypass the identity check. Body/param values are now intentionally ignored. - req.kyc now includes smeId so downstream handlers can reference it without re-reading req.user. - Expanded JSDoc to document every gated endpoint and the security contract. src/routes/invoiceStateRoutes.js - POST /:id/link-escrow now requires requireKycForFunding (initiates escrow funding lifecycle — capital-movement entry point). - POST /:id/transition uses a new conditionalKycGate that applies requireKycForFunding only when targetState ∈ {funded, settled}; non-capital transitions (approved, rejected) are unaffected. - CAPITAL_MOVING_STATES set defined at module level for clarity. tests/kyc.gating.test.js - Full rewrite covering all gated routes and the anti-spoofing guarantee. - Sections: ConfigSchema regression, checkKycHealth, middleware unit tests (auth, smeId resolution, KYC status gate, req.kyc attachment), fund-invoice route, link-escrow route, transition route (conditional gating), and smeId spoofing via body/params. docs/compliance.md - Updated gated-endpoint inventory table. - Documented the anti-spoofing fix with before/after code. - Phase 1 roadmap updated to mark issue #222 complete. - Version bumped to 1.1.0. --- docs/compliance.md | 94 +++++-- src/middleware/kycGating.js | 75 +++-- src/routes/invoiceStateRoutes.js | 46 +++- tests/kyc.gating.test.js | 459 ++++++++++++++++++++++++++++--- 4 files changed, 582 insertions(+), 92 deletions(-) diff --git a/docs/compliance.md b/docs/compliance.md index dd908772..8f84bb06 100644 --- a/docs/compliance.md +++ b/docs/compliance.md @@ -2,11 +2,12 @@ ## Overview -This document outlines the KYC (Know Your Customer) compliance framework implemented in the LiquiFact backend. The system enforces SME identity verification before allowing capital deployment through funding endpoints. +This document outlines the KYC (Know Your Customer) compliance framework implemented in the LiquiFact backend. The system enforces SME identity verification before allowing capital deployment through **all** funding and settlement endpoints. **Status**: Production-ready implementation with optional external provider integration. -**Date**: April 2026 -**Version**: 1.0.0 +**Date**: May 2026 +**Version**: 1.1.0 +**Relates to**: Issue #222 — Enforce KYC gating on all capital-movement endpoints --- @@ -83,26 +84,46 @@ kycService.canFundWithKycStatus(status) → boolean **File**: `src/middleware/kycGating.js` -The `requireKycForFunding` middleware enforces KYC requirements on sensitive endpoints: +The `requireKycForFunding` middleware enforces KYC requirements on **all** capital-movement endpoints. + +#### Security contract — smeId resolution (anti-spoofing fix, issue #222) + +Prior to this fix, `smeId` was resolved as +`req.user.smeId || req.body.smeId || req.params.smeId`, which allowed an +authenticated caller to supply a verified SME's ID in the request body or URL +parameter and pass the gate for an SME they do not own. + +**The gate now resolves `smeId` exclusively from `req.user.smeId`** — the JWT +claim set by `authenticateToken`. Body and parameter values are intentionally +ignored during the identity check. ```javascript -app.post('/api/invest/fund-invoice', - authenticateToken, - requireKycForFunding, // ⭐ KYC gate - fundingHandler -); +// ✅ CORRECT — smeId tied to authenticated principal +const smeId = req.user.smeId || null; + +// ❌ OLD (vulnerable) — body/params could be spoofed +// const smeId = req.user.smeId || req.body?.smeId || req.params?.smeId; ``` +#### Gated endpoints + +| Endpoint | Method | Gate | +|---|---|---| +| `/api/invest/fund-invoice` | POST | `requireKycForFunding` | +| `/api/invoices/:id/link-escrow` | POST | `requireKycForFunding` | +| `/api/invoices/:id/transition` | POST | `conditionalKycGate` (only when `targetState` ∈ `{funded, settled}`) | + **Behavior**: 1. Validates user is authenticated -2. Extracts SME ID from request (via JWT claim, body, or params) -3. Checks KYC status for that SME -4. Returns 403 if status is not 'verified' or 'exempted' -5. Attaches KYC metadata to `req.kyc` for downstream handlers +2. Extracts `smeId` exclusively from the JWT (`req.user.smeId`) +3. Returns `400 MISSING_SME_ID` if the JWT contains no `smeId` claim +4. Checks KYC status for the authenticated SME +5. Returns `403 KYC_GATE_FAILED` if status is not `'verified'` or `'exempted'` +6. Attaches `{ smeId, status, recordId, verifiedAt }` to `req.kyc` for downstream handlers **Error Codes**: - `401 UNAUTHORIZED`: No authentication -- `400 MISSING_SME_ID`: SME ID not provided +- `400 MISSING_SME_ID`: JWT contains no `smeId` claim - `403 KYC_GATE_FAILED`: KYC verification not met - `500 KYC_CHECK_FAILED`: Service error during KYC lookup @@ -114,7 +135,7 @@ app.post('/api/invest/fund-invoice', #### POST /api/invest/fund-invoice -Initiates capital transfer to escrow. **Requires KYC verification**. +Initiates capital transfer to escrow. **Requires KYC verification** (`smeId` from JWT). **Request**: ```json @@ -235,6 +256,22 @@ curl -X POST http://localhost:3001/api/invest/fund-invoice \ --- +#### POST /api/invoices/:id/link-escrow *(added — issue #222)* + +Links an approved invoice into the escrow funding lifecycle. **Requires KYC verification**. + +The `smeId` is resolved from `req.user.smeId` (JWT). If absent, returns `400 MISSING_SME_ID`. + +--- + +#### POST /api/invoices/:id/transition *(conditionally gated — issue #222)* + +Executes an invoice state transition. KYC is required only when the `targetState` is a +capital-moving state (`funded` or `settled`). Non-capital transitions (`approved`, `rejected`) +are not blocked by this gate. + +--- + ## Environment Configuration ### Optional KYC Provider Integration @@ -303,8 +340,9 @@ if (!validation.valid) { 3. **Tenant Isolation**: Each request includes tenant context (via header or JWT) 4. **Rate Limiting**: KYC endpoints subject to sensitive rate limits (40 req/hour) -**Middleware Stack**: +**Middleware Stack** (capital-movement endpoints): ```javascript +// Example: POST /api/invest/fund-invoice app.post('/api/invest/fund-invoice', requestIdMiddleware, // Add request ID pinoHttpLogger, // Log request @@ -315,13 +353,18 @@ app.post('/api/invest/fund-invoice', sentryRequestHandler, // Error tracking rateLimiter, // 40 req/hour for sensitive ops auditMiddleware, // Log mutation - authenticateToken, // ⭐ Verify JWT - tenantMiddleware, // ⭐ Extract tenant - requireKycForFunding, // ⭐ KYC gate + authenticateToken, // ⭐ Verify JWT (sets req.user) + tenantMiddleware, // ⭐ Extract tenant (sets req.tenantId) + requireKycForFunding, // ⭐ KYC gate (smeId from JWT only) fundingHandler // Business logic ); ``` +> **Security note**: `smeId` for KYC lookup is resolved exclusively from +> `req.user.smeId` (the verified JWT claim). Callers cannot supply a spoofed +> `smeId` via `req.body` or `req.params` to pass the gate for an SME they do +> not own. + ### Key Handling **For external KYC provider integration**: @@ -481,11 +524,15 @@ curl -X POST http://localhost:3001/api/invest/fund-invoice \ ## Roadmap & Future Work -### Phase 1: Current (✅ Complete) +### Phase 1: Complete ✅ - ✅ Invoice schema with kycStatus field - ✅ KYC service with mock implementation - ✅ KYC gating middleware -- ✅ Funding endpoint protection +- ✅ Funding endpoint protection (`POST /api/invest/fund-invoice`) +- ✅ **KYC gate on ALL capital-movement endpoints** (issue #222) + - ✅ `POST /api/invoices/:id/link-escrow` + - ✅ `POST /api/invoices/:id/transition` (capital-moving states) +- ✅ **Anti-spoofing: smeId resolved from JWT only** (issue #222) - ✅ Comprehensive testing (95%+ coverage) - ✅ Documentation @@ -570,5 +617,6 @@ Before production deployment: --- -**Last Updated**: April 25, 2026 -**Maintained By**: LiquiFact Backend Team +**Last Updated**: May 28, 2026 +**Maintained By**: LiquiFact Backend Team +**Related Issues**: #222 — Enforce KYC gating on all capital-movement endpoints diff --git a/src/middleware/kycGating.js b/src/middleware/kycGating.js index 98825f9c..783b3c56 100644 --- a/src/middleware/kycGating.js +++ b/src/middleware/kycGating.js @@ -1,7 +1,7 @@ /** * KYC Gating Middleware * Enforces KYC requirements before allowing access to sensitive endpoints - * + * * @module middleware/kycGating */ @@ -10,26 +10,32 @@ const kycService = require('../services/kycService'); const logger = require('../logger'); /** - * Middleware to enforce KYC verification for funding operations - * - * Should be applied to: + * Middleware to enforce KYC verification for all capital-movement operations. + * + * Apply to every route that initiates or settles escrow funding: * - POST /api/invest/fund-invoice - * - POST /api/invoices/:id/fund - * - Any endpoint that initiates capital transfer - * - * Requirements: - * - User must be authenticated (req.user must exist) - * - SME must have KYC status of 'verified' or 'exempted' - * - Tenant isolation is enforced (via tenant middleware) - * - * @param {Object} req - Express request object - * @param {Object} res - Express response object - * @param {Function} next - Express next middleware - * @throws {AppError} 403 if KYC requirements not met + * - POST /api/invoices/:id/link-escrow + * - POST /api/invoices/:id/transition (when targetState is a capital-moving state) + * - Any future endpoint that transfers or releases capital + * + * Security contract: + * - User MUST be authenticated (`req.user` populated by `authenticateToken`). + * - `smeId` is resolved ONLY from the authenticated JWT principal (`req.user.smeId`). + * Callers CANNOT override this via `req.body.smeId` or `req.params.smeId` — doing + * so would allow an attacker to supply a verified SME's ID they do not own. + * - SME must hold KYC status of 'verified' or 'exempted'. + * - Tenant isolation is enforced upstream (via `extractTenant` middleware). + * + * @param {import('express').Request} req - Express request object + * @param {import('express').Response} res - Express response object + * @param {import('express').NextFunction} next - Express next middleware + * @throws {AppError} 401 if unauthenticated + * @throws {AppError} 400 if the JWT contains no smeId claim + * @throws {AppError} 403 if KYC requirements are not met */ async function requireKycForFunding(req, res, next) { try { - // 1. Validate authentication + // 1. Validate authentication — authenticateToken must have run first. if (!req.user || !req.user.sub) { const error = new AppError({ type: 'https://liquifact.com/probs/unauthorized', @@ -42,23 +48,31 @@ async function requireKycForFunding(req, res, next) { return next(error); } - // 2. Extract SME ID from request - // Can come from: req.user.smeId, req.body.smeId, or req.params.smeId - const smeId = req.user.smeId || req.body?.smeId || req.params?.smeId; + // 2. Resolve smeId STRICTLY from the authenticated JWT principal. + // + // SECURITY NOTE: We intentionally do NOT fall back to req.body.smeId or + // req.params.smeId. If we did, any authenticated user could supply a + // verified SME's ID in the request body/params and pass the KYC gate for + // an SME they do not own. The smeId MUST come from the token that was + // issued to this specific principal. + // + // Convention: tokens may carry the SME identity as either `smeId` or, + // for tokens where the subject *is* the SME, as `sub`. + const smeId = req.user.smeId || null; if (!smeId) { const error = new AppError({ type: 'https://liquifact.com/probs/validation-error', title: 'Validation Error', status: 400, - detail: 'SME ID is required for funding operations.', + detail: 'SME ID is required for funding operations. Ensure your JWT contains a valid smeId claim.', instance: req.originalUrl, code: 'MISSING_SME_ID', }); return next(error); } - // 3. Check KYC status + // 3. Check KYC status for the authenticated principal's SME. const kycRecord = await kycService.getKycStatus(smeId); const canFund = kycService.canFundWithKycStatus(kycRecord.status); @@ -73,7 +87,7 @@ async function requireKycForFunding(req, res, next) { 'KYC gate check' ); - // 4. Enforce gate + // 4. Enforce gate — block if KYC is not in an acceptable state. if (!canFund) { const error = new AppError({ type: 'https://liquifact.com/probs/kyc-required', @@ -88,8 +102,9 @@ async function requireKycForFunding(req, res, next) { return next(error); } - // 5. Attach KYC info to request for downstream handlers + // 5. Attach verified KYC metadata to the request for downstream handlers. req.kyc = { + smeId, status: kycRecord.status, recordId: kycRecord.recordId, verifiedAt: kycRecord.verifiedAt, @@ -121,16 +136,20 @@ async function requireKycForFunding(req, res, next) { } /** - * Optional: Middleware to log KYC checks for audit trails - * Attach to general routes to track KYC interactions + * Middleware to log KYC access for audit trails. + * Attach after `requireKycForFunding` on gated routes to record every + * successful capital-movement access. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next */ async function auditKycAccess(req, res, next) { - // Extract KYC info if available if (req.kyc) { logger.debug( { userId: req.user?.sub, - smeId: req.user?.smeId, + smeId: req.kyc.smeId, kycStatus: req.kyc.status, endpoint: req.path, method: req.method, diff --git a/src/routes/invoiceStateRoutes.js b/src/routes/invoiceStateRoutes.js index f011bbb6..69b3ef91 100644 --- a/src/routes/invoiceStateRoutes.js +++ b/src/routes/invoiceStateRoutes.js @@ -1,7 +1,11 @@ /** * Invoice State Transition Routes - * Handles invoice lifecycle state transitions with audit logging - * + * Handles invoice lifecycle state transitions with audit logging. + * + * Capital-movement routes are protected by the KYC gate: + * - POST /:id/link-escrow — initiates escrow funding lifecycle + * - POST /:id/transition — when targetState is 'funded' or 'settled' + * * @module routes/invoiceStateRoutes */ @@ -15,6 +19,18 @@ const { canLinkToEscrow, } = require('../services/invoiceStateMachine'); const { getAuditLogs } = require('../services/auditLog'); +const { requireKycForFunding } = require('../middleware/kycGating'); + +/** + * States that initiate or settle capital movement and therefore require + * the caller to be KYC-verified before transitioning to them. + */ +const CAPITAL_MOVING_STATES = new Set([ + 'funded', + 'settled', + INVOICE_STATES.FUNDED, + INVOICE_STATES.SETTLED, +].filter(Boolean)); /** * Helper to extract actor from request @@ -85,7 +101,24 @@ router.get('/:id/state', (req, res) => { * "reason": "Invoice verified and approved by finance team" * } */ -router.post('/:id/transition', (req, res, next) => { +/** + * KYC gate selector for transition endpoint. + * Runs `requireKycForFunding` only when the requested targetState is a + * capital-moving state; passes through for non-capital transitions. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +function conditionalKycGate(req, res, next) { + const { targetState } = req.body || {}; + if (targetState && CAPITAL_MOVING_STATES.has(targetState)) { + return requireKycForFunding(req, res, next); + } + return next(); +} + +router.post('/:id/transition', conditionalKycGate, (req, res, next) => { const { id } = req.params; const { targetState, reason } = req.body; @@ -227,9 +260,12 @@ router.post('/:id/approve', (req, res, next) => { /** * POST /api/invoices/:id/link-escrow - * Link an approved invoice to escrow + * Link an approved invoice to escrow. + * + * This is a capital-movement endpoint: it initiates the escrow funding + * lifecycle. KYC must be verified before the link can be made. */ -router.post('/:id/link-escrow', (req, res, next) => { +router.post('/:id/link-escrow', requireKycForFunding, (req, res, next) => { const { id } = req.params; const { escrowId, reason } = req.body; diff --git a/tests/kyc.gating.test.js b/tests/kyc.gating.test.js index b7056fa2..0e7a308e 100644 --- a/tests/kyc.gating.test.js +++ b/tests/kyc.gating.test.js @@ -1,19 +1,34 @@ 'use strict'; /** - * @file KYC config validation and /ready gating tests. + * @file tests/kyc.gating.test.js * - * Covers: - * 1. Valid full config — provider enabled, health check runs - * 2. Partial config — URL without key (and key without URL) rejected at boot - * 3. Disabled (no envs) — provider skipped, /ready still healthy - * 4. Degraded /ready — provider unreachable → 503 + * Covers issue #222 — Enforce KYC gating on ALL capital-movement endpoints. + * + * Test sections: + * 1. ConfigSchema — KYC env vars (existing, kept for regression) + * 2. checkKycHealth — disabled / healthy / degraded + * 3. requireKycForFunding middleware — unit tests + * a. Auth enforcement + * b. smeId resolution (ONLY from JWT — anti-spoofing) + * c. KYC status gate (pending / rejected / verified / exempted) + * 4. POST /api/invest/fund-invoice — gated (original endpoint) + * 5. POST /api/invoices/:id/link-escrow — gated (new) + * 6. POST /api/invoices/:id/transition — conditionally gated (new) + * a. Capital-moving states require KYC + * b. Non-capital transitions pass through without KYC + * 7. smeId spoofing — body/params smeId MUST NOT bypass the gate */ +const express = require('express'); +const request = require('supertest'); + const { ConfigSchema } = require('../src/config/index'); const { checkKycHealth, performHealthChecks } = require('../src/services/health'); +const { requireKycForFunding } = require('../src/middleware/kycGating'); +const kycService = require('../src/services/kycService'); -// ── helpers ────────────────────────────────────────────────────────────────── +// ─── helpers ────────────────────────────────────────────────────────────────── /** Minimal valid env for ConfigSchema.parse() */ const BASE_ENV = { @@ -21,7 +36,39 @@ const BASE_ENV = { JWT_SECRET: 'a'.repeat(32), }; -// ── 1. Zod schema: valid full config ───────────────────────────────────────── +/** + * Build a minimal Express app wired with the KYC gate plus a success handler. + * Optionally set `req.user` via the `user` parameter. + * + * @param {{ user?: object, routePath?: string }} [opts] + */ +function buildGatedApp(opts = {}) { + const app = express(); + app.use(express.json()); + + // Fake auth middleware + app.use((req, _res, next) => { + req.user = opts.user !== undefined ? opts.user : { sub: 'user_123', smeId: 'sme_001' }; + req.id = 'req_test'; + next(); + }); + + // Generic error handler that surfaces AppError fields + app.use((err, _req, res, _next) => { + res.status(err.status || 500).json({ + error: { code: err.code, detail: err.detail, status: err.status }, + }); + }); + + const routePath = opts.routePath || '/fund'; + app.post(routePath, requireKycForFunding, (_req, res) => res.status(200).json({ ok: true })); + + return app; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. ConfigSchema — KYC env vars +// ───────────────────────────────────────────────────────────────────────────── describe('ConfigSchema — KYC env vars', () => { it('accepts valid URL + key pair', () => { @@ -46,7 +93,6 @@ describe('ConfigSchema — KYC env vars', () => { const result = ConfigSchema.safeParse({ ...BASE_ENV, KYC_PROVIDER_URL: 'https://kyc.example.com', - // KYC_PROVIDER_API_KEY intentionally absent }); expect(result.success).toBe(false); const paths = result.error.issues.map((i) => i.path.join('.')); @@ -57,7 +103,6 @@ describe('ConfigSchema — KYC env vars', () => { const result = ConfigSchema.safeParse({ ...BASE_ENV, KYC_PROVIDER_API_KEY: 'secret-key', - // KYC_PROVIDER_URL intentionally absent }); expect(result.success).toBe(false); const paths = result.error.issues.map((i) => i.path.join('.')); @@ -69,7 +114,6 @@ describe('ConfigSchema — KYC env vars', () => { ...BASE_ENV, NODE_ENV: 'test', KYC_PROVIDER_URL: 'https://kyc.example.com', - // KYC_PROVIDER_API_KEY absent — allowed in test }); expect(result.success).toBe(true); }); @@ -84,7 +128,9 @@ describe('ConfigSchema — KYC env vars', () => { }); }); -// ── 2. checkKycHealth — disabled ───────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// 2. checkKycHealth +// ───────────────────────────────────────────────────────────────────────────── describe('checkKycHealth — disabled (no envs)', () => { beforeEach(() => { @@ -98,8 +144,6 @@ describe('checkKycHealth — disabled (no envs)', () => { }); }); -// ── 3. checkKycHealth — healthy provider ───────────────────────────────────── - describe('checkKycHealth — healthy provider', () => { const originalFetch = global.fetch; @@ -116,44 +160,33 @@ describe('checkKycHealth — healthy provider', () => { it('returns healthy when provider responds 200', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }); - const result = await checkKycHealth(); - expect(result.status).toBe('healthy'); expect(typeof result.latency).toBe('number'); - - // API key must NOT appear in the response object expect(JSON.stringify(result)).not.toContain('test-api-key'); }); it('sends Authorization header with the API key', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }); - await checkKycHealth(); - const [, options] = global.fetch.mock.calls[0]; expect(options.headers.Authorization).toBe('Bearer test-api-key'); }); it('uses HEAD method (lightweight probe)', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }); - await checkKycHealth(); - const [, options] = global.fetch.mock.calls[0]; expect(options.method).toBe('HEAD'); }); it('returns healthy for 4xx (host reachable)', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 401 }); - const result = await checkKycHealth(); expect(result.status).toBe('healthy'); }); }); -// ── 4. checkKycHealth — degraded provider ──────────────────────────────────── - describe('checkKycHealth — degraded provider', () => { const originalFetch = global.fetch; @@ -170,7 +203,6 @@ describe('checkKycHealth — degraded provider', () => { it('returns unhealthy when provider responds 5xx', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503 }); - const result = await checkKycHealth(); expect(result.status).toBe('unhealthy'); expect(result.error).toMatch(/503/); @@ -178,22 +210,18 @@ describe('checkKycHealth — degraded provider', () => { it('returns unhealthy when fetch throws (network error)', async () => { global.fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); - const result = await checkKycHealth(); expect(result.status).toBe('unhealthy'); expect(result.error).toMatch(/ECONNREFUSED/); }); }); -// ── 5. performHealthChecks — /ready degraded state ─────────────────────────── - describe('performHealthChecks — /ready degraded when KYC unhealthy', () => { const originalFetch = global.fetch; beforeEach(() => { process.env.KYC_PROVIDER_URL = 'https://kyc.example.com'; process.env.KYC_PROVIDER_API_KEY = 'test-api-key'; - // Soroban URL must be set so it doesn't return 'unknown' (which counts as healthy) process.env.SOROBAN_RPC_URL = 'https://soroban-testnet.stellar.org'; }); @@ -205,12 +233,9 @@ describe('performHealthChecks — /ready degraded when KYC unhealthy', () => { it('healthy=false when KYC provider is unreachable', async () => { global.fetch = jest.fn().mockImplementation((url) => { - if (url.includes('soroban')) { - return Promise.resolve({ ok: true, status: 200 }); - } + if (url.includes('soroban')) return Promise.resolve({ ok: true, status: 200 }); return Promise.reject(new Error('ECONNREFUSED')); }); - const { healthy, checks } = await performHealthChecks(); expect(healthy).toBe(false); expect(checks.kyc.status).toBe('unhealthy'); @@ -219,11 +244,373 @@ describe('performHealthChecks — /ready degraded when KYC unhealthy', () => { it('healthy=true when KYC is disabled and soroban is healthy', async () => { delete process.env.KYC_PROVIDER_URL; delete process.env.KYC_PROVIDER_API_KEY; - global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }); - const { healthy, checks } = await performHealthChecks(); expect(healthy).toBe(true); expect(checks.kyc.status).toBe('disabled'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. requireKycForFunding middleware — unit tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('requireKycForFunding — authentication enforcement', () => { + beforeEach(() => kycService.resetMockRecords()); + + it('returns 401 when req.user is absent', async () => { + const app = buildGatedApp({ user: null }); + const res = await request(app).post('/fund'); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe('UNAUTHORIZED'); + }); + + it('returns 401 when req.user.sub is absent', async () => { + const app = buildGatedApp({ user: { smeId: 'sme_001' } }); // no sub + const res = await request(app).post('/fund'); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe('UNAUTHORIZED'); + }); +}); + +describe('requireKycForFunding — smeId resolution (JWT only)', () => { + beforeEach(() => kycService.resetMockRecords()); + + it('returns 400 when JWT contains no smeId claim', async () => { + // sub is present but smeId is absent from the JWT + const app = buildGatedApp({ user: { sub: 'user_no_sme' } }); + const res = await request(app).post('/fund').send({ smeId: 'sme_verified' }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('MISSING_SME_ID'); + }); + + it('resolves smeId from req.user.smeId (JWT claim)', async () => { + await kycService.verifySmeSafe('sme_from_jwt'); + const app = buildGatedApp({ user: { sub: 'user_1', smeId: 'sme_from_jwt' } }); + const res = await request(app).post('/fund'); + expect(res.status).toBe(200); + }); +}); + +describe('requireKycForFunding — KYC status gate', () => { + beforeEach(() => kycService.resetMockRecords()); + + it('returns 403 for an SME with pending KYC', async () => { + // sme_pending has no record → defaults to pending + const app = buildGatedApp({ user: { sub: 'u1', smeId: 'sme_pending' } }); + const res = await request(app).post('/fund').send({}); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('KYC_GATE_FAILED'); + }); + + it('returns 403 for an SME with rejected KYC', async () => { + await kycService.rejectSmeKyc('sme_rejected', 'Failed documents'); + const app = buildGatedApp({ user: { sub: 'u2', smeId: 'sme_rejected' } }); + const res = await request(app).post('/fund').send({}); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('KYC_GATE_FAILED'); + }); + + it('allows through an SME with verified KYC', async () => { + await kycService.verifySmeSafe('sme_verified'); + const app = buildGatedApp({ user: { sub: 'u3', smeId: 'sme_verified' } }); + const res = await request(app).post('/fund').send({}); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + }); + + it('allows through an SME with exempted KYC', async () => { + await kycService.exemptSmeFromKyc('sme_exempted', 'Policy exemption'); + const app = buildGatedApp({ user: { sub: 'u4', smeId: 'sme_exempted' } }); + const res = await request(app).post('/fund').send({}); + expect(res.status).toBe(200); + }); + + it('attaches req.kyc with the resolved smeId for downstream handlers', async () => { + await kycService.verifySmeSafe('sme_attach_test'); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { sub: 'u5', smeId: 'sme_attach_test' }; + req.id = 'req_attach'; + next(); + }); + app.post('/fund', requireKycForFunding, (req, res) => { + res.json({ kyc: req.kyc }); + }); + const res = await request(app).post('/fund'); + expect(res.status).toBe(200); + expect(res.body.kyc.smeId).toBe('sme_attach_test'); + expect(res.body.kyc.status).toBe('verified'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. POST /api/invest/fund-invoice — original gated endpoint +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/invest/fund-invoice — KYC gate', () => { + let app; + + beforeAll(() => { + // We test the route in isolation using the invest router + const investRouter = require('../src/routes/invest'); + app = express(); + app.use(express.json()); + + // Simulate auth + tenant resolution + app.use((req, _res, next) => { + req.user = { sub: 'investor_1', smeId: req.headers['x-sme-id'] || 'sme_default' }; + req.tenantId = 'tenant_test'; + req.id = 'req_fund_invoice'; + next(); + }); + app.use('/api/invest', investRouter); + + // Generic error handler + app.use((err, _req, res, _next) => { + res.status(err.status || 500).json({ error: { code: err.code } }); + }); + }); + + beforeEach(() => kycService.resetMockRecords()); + + it('returns 403 when caller SME is not KYC-verified', async () => { + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('x-sme-id', 'sme_not_verified') + .send({ invoiceId: 'inv_001', investmentAmount: 500, smeId: 'sme_not_verified' }); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('KYC_GATE_FAILED'); + }); + + it('returns 201 when caller SME is KYC-verified', async () => { + await kycService.verifySmeSafe('sme_verified_inv'); + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('x-sme-id', 'sme_verified_inv') + .send({ invoiceId: 'inv_001', investmentAmount: 500, smeId: 'sme_verified_inv' }); + + expect(res.status).toBe(201); + }); + + it('returns 201 when caller SME is KYC-exempted', async () => { + await kycService.exemptSmeFromKyc('sme_exempted_inv'); + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('x-sme-id', 'sme_exempted_inv') + .send({ invoiceId: 'inv_001', investmentAmount: 500, smeId: 'sme_exempted_inv' }); + + expect(res.status).toBe(201); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. POST /api/invoices/:id/link-escrow — newly gated +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/invoices/:id/link-escrow — KYC gate (issue #222)', () => { + let app; + + beforeAll(() => { + const invoiceStateRouter = require('../src/routes/invoiceStateRoutes'); + app = express(); + app.use(express.json()); + + app.use((req, _res, next) => { + req.user = { sub: 'user_link', smeId: req.headers['x-sme-id'] || null }; + req.tenantId = 'tenant_test'; + req.id = 'req_link'; + next(); + }); + app.use('/api/invoices', invoiceStateRouter); + + app.use((err, _req, res, _next) => { + res.status(err.status || 500).json({ error: { code: err.code, status: err.status } }); + }); + }); + + beforeEach(() => kycService.resetMockRecords()); + + it('returns 403 when SME is not KYC-verified', async () => { + const res = await request(app) + .post('/api/invoices/inv-002/link-escrow') + .set('x-sme-id', 'sme_pending_link') + .send({ escrowId: 'escrow_001' }); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('KYC_GATE_FAILED'); + }); + + it('returns 400 when no smeId in JWT (gate fires before business logic)', async () => { + const res = await request(app) + .post('/api/invoices/inv-002/link-escrow') + // no x-sme-id header → req.user.smeId is null + .send({ escrowId: 'escrow_001' }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('MISSING_SME_ID'); + }); + + it('passes the KYC gate for a verified SME and proceeds to business logic', async () => { + await kycService.verifySmeSafe('sme_verified_link'); + const res = await request(app) + .post('/api/invoices/inv-002/link-escrow') + .set('x-sme-id', 'sme_verified_link') + .send({ escrowId: 'escrow_001' }); + + // The underlying handler may return 200 or 400 (business rule: inv-002 + // is already linked_escrow in mock data), but NOT 403 — gate was passed. + expect(res.status).not.toBe(403); + expect(res.status).not.toBe(401); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 6. POST /api/invoices/:id/transition — conditionally gated (issue #222) +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/invoices/:id/transition — capital-moving states require KYC', () => { + let app; + + beforeAll(() => { + const invoiceStateRouter = require('../src/routes/invoiceStateRoutes'); + app = express(); + app.use(express.json()); + + app.use((req, _res, next) => { + req.user = { sub: 'user_trans', smeId: req.headers['x-sme-id'] || null }; + req.tenantId = 'tenant_test'; + req.id = 'req_trans'; + next(); + }); + app.use('/api/invoices', invoiceStateRouter); + + app.use((err, _req, res, _next) => { + res.status(err.status || 500).json({ error: { code: err.code, status: err.status } }); + }); + }); + + beforeEach(() => kycService.resetMockRecords()); + + it('blocks transition to "funded" for non-verified SME with 403', async () => { + const res = await request(app) + .post('/api/invoices/inv-002/transition') + .set('x-sme-id', 'sme_pending_trans') + .send({ targetState: 'funded', reason: 'test' }); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('KYC_GATE_FAILED'); + }); + + it('blocks transition to "settled" for non-verified SME with 403', async () => { + const res = await request(app) + .post('/api/invoices/inv-002/transition') + .set('x-sme-id', 'sme_pending_settle') + .send({ targetState: 'settled', reason: 'test' }); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('KYC_GATE_FAILED'); + }); + + it('allows transition to "approved" WITHOUT a KYC check', async () => { + // sme_no_kyc has no record (pending) but approve is not capital-moving + const res = await request(app) + .post('/api/invoices/inv-001/transition') + .set('x-sme-id', null) // no smeId in token — should still pass gate + .send({ targetState: 'approved', reason: 'Looks good' }); + + // Either succeeds or fails for business reasons — never 403 KYC_GATE_FAILED + expect(res.status).not.toBe(403); + if (res.body.error) { + expect(res.body.error.code).not.toBe('KYC_GATE_FAILED'); + } + }); + + it('allows transition to "rejected" WITHOUT a KYC check', async () => { + const res = await request(app) + .post('/api/invoices/inv-001/transition') + .set('x-sme-id', null) + .send({ targetState: 'rejected', reason: 'Docs missing' }); + + expect(res.status).not.toBe(403); + if (res.body.error) { + expect(res.body.error.code).not.toBe('KYC_GATE_FAILED'); + } + }); + + it('passes "funded" transition for a verified SME', async () => { + await kycService.verifySmeSafe('sme_verified_fund'); + const res = await request(app) + .post('/api/invoices/inv-002/transition') + .set('x-sme-id', 'sme_verified_fund') + .send({ targetState: 'funded', reason: 'Capital deployed' }); + + // Gate passed; business logic may succeed or fail (mock state machine) + // but must NOT be 403 KYC_GATE_FAILED + expect(res.status).not.toBe(403); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 7. smeId SPOOFING — body / params MUST NOT bypass the gate +// ───────────────────────────────────────────────────────────────────────────── + +describe('smeId spoofing — cannot bypass gate via body or params', () => { + beforeEach(() => kycService.resetMockRecords()); + + it('ignores a verified smeId in req.body when JWT smeId is not verified', async () => { + // Mark a different SME as verified + await kycService.verifySmeSafe('sme_verified_other'); + + // Attacker's JWT: smeId = sme_attacker (pending, not verified) + // Attacker tries to supply sme_verified_other in the body + const app = buildGatedApp({ user: { sub: 'attacker', smeId: 'sme_attacker' } }); + + const res = await request(app) + .post('/fund') + .send({ smeId: 'sme_verified_other' }); // spoofed body smeId + + // Gate MUST block based on JWT smeId (sme_attacker = pending) + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('KYC_GATE_FAILED'); + }); + + it('ignores a verified smeId in req.params when JWT smeId is not verified', async () => { + await kycService.verifySmeSafe('sme_verified_other'); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { sub: 'attacker2', smeId: 'sme_attacker2' }; // not verified + req.id = 'req_spoof_params'; + next(); + }); + app.use((err, _req, res, _next) => { + res.status(err.status || 500).json({ error: { code: err.code } }); + }); + // Route with :smeId param — attacker supplies sme_verified_other in URL + app.post('/:smeId/fund', requireKycForFunding, (_req, res) => res.json({ ok: true })); + + const res = await request(app).post('/sme_verified_other/fund'); + + // Must block — JWT smeId (sme_attacker2) is not verified + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('KYC_GATE_FAILED'); + }); + + it('does NOT block when JWT smeId itself is verified, regardless of body smeId', async () => { + await kycService.verifySmeSafe('sme_legit'); + + // Caller supplies a random unverified smeId in body — but their JWT is verified + const app = buildGatedApp({ user: { sub: 'legit_user', smeId: 'sme_legit' } }); + + const res = await request(app) + .post('/fund') + .send({ smeId: 'some_other_unverified_sme' }); // should be ignored + + // Gate should pass because JWT smeId is verified + expect(res.status).toBe(200); + }); +}); From 462eafd4a3ca7c75f985dfdb117458886890ddae Mon Sep 17 00:00:00 2001 From: Mrwicks00 Date: Thu, 28 May 2026 02:20:04 +0100 Subject: [PATCH 2/2] fix(kyc): add missing JSDoc @returns to satisfy jsdoc/require-returns lint rule - requireKycForFunding: add @returns {Promise} - auditKycAccess: add @returns {Promise} - conditionalKycGate: add @returns {void} These were flagged by CI lint on the changed files only. --- src/middleware/kycGating.js | 2 ++ src/routes/invoiceStateRoutes.js | 1 + 2 files changed, 3 insertions(+) diff --git a/src/middleware/kycGating.js b/src/middleware/kycGating.js index 783b3c56..0c4e9383 100644 --- a/src/middleware/kycGating.js +++ b/src/middleware/kycGating.js @@ -29,6 +29,7 @@ const logger = require('../logger'); * @param {import('express').Request} req - Express request object * @param {import('express').Response} res - Express response object * @param {import('express').NextFunction} next - Express next middleware + * @returns {Promise} * @throws {AppError} 401 if unauthenticated * @throws {AppError} 400 if the JWT contains no smeId claim * @throws {AppError} 403 if KYC requirements are not met @@ -143,6 +144,7 @@ async function requireKycForFunding(req, res, next) { * @param {import('express').Request} req * @param {import('express').Response} res * @param {import('express').NextFunction} next + * @returns {Promise} */ async function auditKycAccess(req, res, next) { if (req.kyc) { diff --git a/src/routes/invoiceStateRoutes.js b/src/routes/invoiceStateRoutes.js index 69b3ef91..970a18ec 100644 --- a/src/routes/invoiceStateRoutes.js +++ b/src/routes/invoiceStateRoutes.js @@ -109,6 +109,7 @@ router.get('/:id/state', (req, res) => { * @param {import('express').Request} req * @param {import('express').Response} res * @param {import('express').NextFunction} next + * @returns {void} */ function conditionalKycGate(req, res, next) { const { targetState } = req.body || {};