diff --git a/PERFORMANCE_GUIDE.md b/PERFORMANCE_GUIDE.md index 1cdc2f26..84b4e0f4 100644 --- a/PERFORMANCE_GUIDE.md +++ b/PERFORMANCE_GUIDE.md @@ -9,8 +9,9 @@ This guide explains the comprehensive performance monitoring and optimization sy 3. [Cursor-Based Pagination](#cursor-based-pagination) 4. [Database Connection Pooling](#database-connection-pooling) 5. [Redis Caching Layer](#redis-caching-layer) -6. [Monitoring Dashboard](#monitoring-dashboard) -7. [Performance Budgets](#performance-budgets) +6. [API Response Caching with ETags](#api-response-caching-with-etags) +7. [Monitoring Dashboard](#monitoring-dashboard) +8. [Performance Budgets](#performance-budgets) --- @@ -376,6 +377,63 @@ Returns: --- +## API Response Caching with ETags + +### Overview + +Two middleware layers cut repeated work for read-heavy endpoints: + +1. **`etag()`** — hashes the response body and answers matching + `If-None-Match` headers with a `304 Not Modified`, so clients and CDNs can + skip re-downloading unchanged payloads. +2. **`cacheControl()`** — emits cache headers and, with `inMemory: true`, + stores the body so subsequent requests are served straight from memory + without re-running the handler. Error responses are never cached, and + stale content can be served briefly behind a re-fetch + (`stale-while-revalidate`). + +### Implementation + +Located in: `backend/src/middleware/etag.ts` and `backend/src/middleware/cache.ts` + +```ts +import { etag } from '@/middleware/etag'; +import { cacheControl, CacheTTL } from '@/middleware/cache'; + +// Bandwidth savings without storage +router.get('/api/v1/payments/:id', etag(), handler); + +// Serve stable catalog data from memory (Cache-Control + ETag + X-Cache) +router.get('/api/v1/catalog', cacheControl({ + maxAge: CacheTTL.STATIC, + inMemory: true, + staleWhileRevalidate: 60, +}), handler); +``` + +### Response Semantics + +- `X-Cache: MISS/HIT/STALE` indicates whether the handler ran or a stored body + was served. +- `If-None-Match` matches produce `304 Not Modified` (weak comparison). +- `statusCode >= 400` responses always emit `Cache-Control: no-store` and are + never stored or tagged. +- Mutations (POST/PUT/PATCH/DELETE) pass straight through without caching. + +### Warming & Invalidation + +`warmCache()` pre-loads hot keys on startup; `invalidateCache()` clears entries +matching a glob against the internal `agenticpay:cache:` prefix. + +### Further Reading + +See [backend/docs/RESPONSE_CACHING.md](backend/docs/RESPONSE_CACHING.md) for the +full API reference, `CacheTTL` presets, composition guidance, and benchmark +results for the `cache_plain`, `cache_header_only`, `cache_memory_hit`, and +`cache_etag_304` endpoints. + +--- + ## Monitoring Dashboard ### Performance Overview diff --git a/README.md b/README.md index fc59b243..5bf3c85f 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,18 @@ NODE_ENV=development # CORS Configuration CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 +# CORS is a dynamic, runtime-mutable whitelist. +# See backend/docs/CORS_GUIDE.md for patterns, the management API, and security. + +# Webhook Signature Verification +# Inbound custom webhooks are verified with versioned, rotating HMAC keys. +# See backend/docs/WEBHOOK_KEY_ROTATION.md for the key lifecycle and rotation API. + +# Circuit Breaker +# External service calls (Stripe, Stellar/Horizon, RPC, webhooks, Vault) are +# guarded by a fast-failing, self-recovering circuit breaker with per-service +# isolation. See backend/docs/CIRCUIT_BREAKER.md for the state machine, config, +# and management API. # Stellar Configuration STELLAR_NETWORK=testnet @@ -159,7 +171,7 @@ JOBS_ENABLED=true |---|---|---|---| | `PORT` | `3001` | Server port | No | | `NODE_ENV` | `development` | Environment (development/production) | No | -| `CORS_ALLOWED_ORIGINS` | `*` | Comma-separated list of allowed origins | No | +| `CORS_ALLOWED_ORIGINS` | `*` | Comma-separated list of allowed origins (runtime-mutable; see `backend/docs/CORS_GUIDE.md`) | No | | `STELLAR_NETWORK` | `testnet` | Stellar network (testnet/public) | No | | `OPENAI_API_KEY` | - | OpenAI API key for AI verification/invoicing | **Yes** | | `JOBS_ENABLED` | `true` | Enable background job scheduler | No | diff --git a/backend/benchmarks/baseline.json b/backend/benchmarks/baseline.json index df0b8881..3a5853aa 100644 --- a/backend/benchmarks/baseline.json +++ b/backend/benchmarks/baseline.json @@ -1,77 +1,286 @@ { - "generatedAt": "2026-05-30T08:13:11.739Z", + "generatedAt": "2026-08-31T11:48:01.791Z", "port": 3099, - "note": "Auto-established with 2.5x headroom", "endpoints": [ { "name": "health", "path": "/health", + "requests": 5599, + "throughput": 627072, "latency": { - "p99": 1625 - } + "average": 4.85, + "p50": 4, + "p99": 16, + "max": 88 + }, + "errors": 0, + "non2xx": 0 }, { "name": "ready", "path": "/ready", + "requests": 6186, + "throughput": 602026.67, "latency": { - "p99": 598 - } + "average": 4.31, + "p50": 4, + "p99": 12, + "max": 29 + }, + "errors": 0, + "non2xx": 0 }, { "name": "sandbox_status", "path": "/api/v1/sandbox/status", + "requests": 6906, + "throughput": 697429.34, "latency": { - "p99": 1195 - } + "average": 3.79, + "p50": 3, + "p99": 11, + "max": 24 + }, + "errors": 0, + "non2xx": 0 }, { "name": "escrow_list", "path": "/api/v1/escrow", + "requests": 7747, + "throughput": 606805.34, "latency": { - "p99": 2340 - } + "average": 3.34, + "p50": 3, + "p99": 9, + "max": 13 + }, + "errors": 0, + "non2xx": 0 }, { "name": "flags", "path": "/api/v1/flags", + "requests": 7709, + "throughput": 675754.67, "latency": { - "p99": 0 - } + "average": 3.38, + "p50": 3, + "p99": 9, + "max": 13 + }, + "errors": 0, + "non2xx": 0 }, { "name": "compression_metrics", "path": "/api/v1/compression/metrics", + "requests": 7514, + "throughput": 661248, "latency": { - "p99": 0 - } + "average": 3.44, + "p50": 3, + "p99": 9, + "max": 15 + }, + "errors": 0, + "non2xx": 0 }, { "name": "pool_metrics", "path": "/api/v1/pool/metrics", + "requests": 8064, + "throughput": 720298.67, "latency": { - "p99": 1070 - } + "average": 3.18, + "p50": 3, + "p99": 8, + "max": 12 + }, + "errors": 0, + "non2xx": 0 }, { "name": "sandbox_payment_process", "path": "/api/v1/sandbox/payments/process", + "requests": 4586, + "throughput": 814677.34, "latency": { - "p99": 1628 - } + "average": 6.01, + "p50": 5, + "p99": 16, + "max": 81 + }, + "errors": 0, + "non2xx": 0 }, { "name": "escrow_create", "path": "/api/v1/escrow", + "requests": 4974, + "throughput": 805802.67, "latency": { - "p99": 2420 - } + "average": 5.51, + "p50": 5, + "p99": 13, + "max": 20 + }, + "errors": 0, + "non2xx": 0 }, { "name": "circuit_breaker", "path": "/api/v1/circuit-breaker", + "requests": 7030, + "throughput": 625578.67, "latency": { - "p99": 1585 - } + "average": 3.73, + "p50": 4, + "p99": 9, + "max": 18 + }, + "errors": 0, + "non2xx": 0 + }, + { + "name": "circuit_breaker_rejected", + "path": "/api/v1/circuit-breaker/rejected", + "requests": 7567, + "throughput": 751701.34, + "latency": { + "average": 3.4, + "p50": 3, + "p99": 8, + "max": 14 + }, + "errors": 0, + "non2xx": 7564 + }, + { + "name": "cache_plain", + "path": "/api/v1/cache/plain", + "requests": 7477, + "throughput": 690432, + "latency": { + "average": 3.46, + "p50": 3, + "p99": 9, + "max": 17 + }, + "errors": 0, + "non2xx": 0 + }, + { + "name": "cache_header_only", + "path": "/api/v1/cache/header", + "requests": 6494, + "throughput": 647082.67, + "latency": { + "average": 4.1, + "p50": 4, + "p99": 10, + "max": 17 + }, + "errors": 0, + "non2xx": 0 + }, + { + "name": "cache_memory_hit", + "path": "/api/v1/cache/memory", + "requests": 7823, + "throughput": 816213.34, + "latency": { + "average": 3.29, + "p50": 3, + "p99": 8, + "max": 13 + }, + "errors": 0, + "non2xx": 0 + }, + { + "name": "cache_etag_304", + "path": "/api/v1/cache/etag-304", + "requests": 8865, + "throughput": 525952, + "latency": { + "average": 2.94, + "p50": 3, + "p99": 7, + "max": 13 + }, + "errors": 0, + "non2xx": 8865 + }, + { + "name": "cors_allowed", + "path": "/api/v1/cors/allowed", + "requests": 6283, + "throughput": 810410.67, + "latency": { + "average": 4.29, + "p50": 4, + "p99": 10, + "max": 21 + }, + "errors": 0, + "non2xx": 0 + }, + { + "name": "cors_wildcard", + "path": "/api/v1/cors/allowed", + "requests": 6107, + "throughput": 802048, + "latency": { + "average": 4.42, + "p50": 4, + "p99": 11, + "max": 17 + }, + "errors": 0, + "non2xx": 0 + }, + { + "name": "cors_preflight", + "path": "/api/v1/cors/allowed", + "requests": 8742, + "throughput": 990720, + "latency": { + "average": 2.96, + "p50": 3, + "p99": 8, + "max": 13 + }, + "errors": 0, + "non2xx": 0 + }, + { + "name": "webhook_verify_valid", + "path": "/api/v1/webhook/verify", + "requests": 4455, + "throughput": 374229.34, + "latency": { + "average": 6.23, + "p50": 6, + "p99": 15, + "max": 33 + }, + "errors": 0, + "non2xx": 0 + }, + { + "name": "webhook_verify_invalid", + "path": "/api/v1/webhook/verify-invalid", + "requests": 4170, + "throughput": 365610.67, + "latency": { + "average": 6.7, + "p50": 6, + "p99": 17, + "max": 37 + }, + "errors": 0, + "non2xx": 4170 } ] } \ No newline at end of file diff --git a/backend/docs/CIRCUIT_BREAKER.md b/backend/docs/CIRCUIT_BREAKER.md new file mode 100644 index 00000000..2b6146a6 --- /dev/null +++ b/backend/docs/CIRCUIT_BREAKER.md @@ -0,0 +1,185 @@ +# Circuit Breaker for External Service Calls + +A production-grade resilience pattern that protects the backend from cascading +failures when an external dependency (Stripe, Stellar/Horizon, an EVM/Soroban +RPC, a notification webhook, a Vault, etc.) degrades or goes down. + +Two cooperating modules implement it: + +- `src/services/circuitBreaker.ts` — a pure, self-contained state machine + (`CircuitBreaker`) plus `src/services/circuitBreakerRegistry.ts` (a registry + that owns named instances). +- `src/middleware/circuit-breaker.ts` — the backward-compatible facade used by + existing callers: `withCircuitBreaker`, the Express `circuitBreaker` middleware, + and the management helpers (`getCircuitState`, `getAllCircuits`, `resetCircuit`). + +## Why / when to use + +The default retry service (`services/retry`) already retries transient failures +with backoff, but it has **no shared, observable trip/open/recover lifecycle**. +A circuit breaker adds: + +- **Fast-fail**: when a downstream is clearly unhealthy, reject immediately + instead of burning timeouts/retries. +- **Bulkhead by name**: each logical service gets its own isolated breaker, so a + flapping Stripe feed cannot trip the Horizon breaker (a security boundary). +- **Orderly recovery**: a bounded number of half-open probes, with enough + consecutive successes returning the breaker to closed. +- **Observability**: serialisable snapshots + state-change events for metrics, + alerting and the management API. + +## State machine + +``` + consecutive failures / failure-rate over sliding window + closed ─────────────────────────────────────────────────────────────► open + ▲ │ + │ successThreshold consecutive │ waitDurationInOpenState + │ successes in half_open ▼ + └─────────────────────────────── half_open ◄───────────────────────────┘ + (bounded permitted calls; a single + failure re-opens) +``` + +- **closed** — all calls pass. Outcomes are accumulated in a sliding window. +- **open** — calls are short-circuited (503 / `CircuitBreakerError`) until + `waitDurationInOpenState` has elapsed since the breaker opened. +- **half_open** — at most `permittedNumberOfCallsInHalfOpenState` trial calls + are allowed. `successThreshold` consecutive successes close the breaker; any + failure re-opens it. + +## Configuration + +| Option | Default | Meaning | +| ------ | ------- | ------- | +| `slidingWindowSize` | 20 | Most-recent outcomes retained for the failure-rate window. | +| `minimumCallsToOpen` | 5 | Min window samples before the rate may trip the breaker. | +| `failureRateThreshold` | 50 | Failure rate (%) over the window that opens the breaker. | +| `failureThreshold` | 5 | Consecutive failures that open the breaker early. | +| `successThreshold` | 2 | Consecutive half-open successes that close the breaker. | +| `waitDurationInOpenState` | 60 000 | Time (ms) the breaker stays open before probing. | +| `permittedNumberOfCallsInHalfOpenState` | 3 | Half-open trial calls per window. | +| `requestTimeoutMs` | 10 000 | Per-call timeout (0 disables the guard, e.g. in tests). | +| `failClosed` | false | Policy lever; when true, closed still permits (see security). | +| `recordFailure` | — | Predicate; return false to observe but not count a failure. | +| `ignoreFailures` | — | Predicate; skip the outcome entirely. | + +Per-call overrides are supported: `protect(fn, fallback, requestTimeoutMs)`. + +## Usage + +### Guard an async external call (preferred) + +```ts +import { withCircuitBreaker } from '../middleware/circuit-breaker.js'; + +// backend/src/services/stripe.ts already does exactly this. +const intent = await withCircuitBreaker('stripe-api', () => + stripe.paymentIntents.create({ amount, currency }), +); +``` + +By default an open breaker **throws** `CircuitBreakerError`. Provide a `fallback` +to degrade gracefully instead: + +```ts +const price = await withCircuitBreaker('fx-service', () => fetchFx(), () => cachedPrice); +``` + +### Guard an Express route + +```ts +import { circuitBreaker } from '../middleware/circuit-breaker.js'; + +app.use('/rpc', circuitBreaker('evm-provider', { failureThreshold: 3 })); +``` + +When open, the route responds `503` with: + +```json +{ + "error": { + "code": "CIRCUIT_OPEN", + "message": "Service evm-provider is temporarily unavailable. Circuit breaker is open.", + "status": 503, + "retryAfterMs": 12345 + } +} +``` + +### Direct, isolated instance + +```ts +import { CircuitBreaker } from '../services/circuitBreaker.js'; + +const cb = new CircuitBreaker('vault', { failureThreshold: 5 }); +const secret = await cb.protect(() => vault.readSecret(name), () => envFallback); +``` + +The clock is injectable (`new CircuitBreaker(name, config, now)`), enabling fully +deterministic tests of the open → half_open → closed cycle without real sleeps. + +### Observability + +```ts +import { getCircuitState, getAllCircuits, resetCircuit } from '../middleware/circuit-breaker.js'; + +getAllCircuits(); // serialisable snapshots (usable in res.json) +getCircuitState('stripe-api'); +resetCircuit('stripe-api'); // force back to closed +``` + +Circuit state is surfaced by the existing management routes +`GET /api/v1/circuit-breaker` and `/api/v1/service-mesh/circuits`. + +## Security considerations + +- **Isolation / bulkhead**: every named circuit is an independent instance with + its own window and thresholds, so one dependency's failure cannot open another + service's breaker. Use one name per logical upstream. +- **Fail-closed policy**: `failClosed` is exposed so operators can require + explicit admission for a given service rather than allowing by default. +- **Health-hint propagation**: an open breaker returns `retryAfterMs` and a + 503 so gateways/clients back off instead of hammering a degraded service. +- **No secrets in state**: snapshots contain only counters/timestamps/config — + never credentials or payloads. +- **Orderly recovery**: half-open probes are bounded and must accumulate + consecutive successes before re-closing, preventing a thundering-herd + re-opening. + +## Backward compatibility + +The facade (`middleware/circuit-breaker.ts`) keeps the public surface unchanged: +`withCircuitBreaker`, `circuitBreaker`, `getCircuitState`, `getAllCircuits`, +`resetCircuit`, `CircuitBreakerError`. Existing consumers (`stripe.ts`, +`stellar.ts`, `transaction-monitor.ts`, `payments/providers/{evm,soroban}.ts`) +continue to work without modification, including the `instanceof +CircuitBreakerError` checks and name-based circuit registration. + +## Testing + +```bash +npx vitest run src/services/__tests__/circuitBreaker.test.ts # state machine + registry (29) +npx vitest run src/middleware/__tests__/circuit-breaker.test.ts # facade API (14) +npx vitest run src/middleware/__tests__/circuit-breaker.integration.test.ts # real HTTP open/recover (5) +``` + +Coverage (v8, scoped via `--coverage.include` to `services/circuitBreaker.ts`, +`services/circuitBreakerRegistry.ts` and `middleware/circuit-breaker.ts`): +statements 97.5%, branches 87.7%, functions 98.1%, lines 98.2% — all above the +80% threshold. + +## Performance + +Per-request overhead is bounded to a constant-time permit check plus (on +completion) outcome accounting. Benchmarks (autocannon, see +`benchmarks/baseline.json`): + +| Endpoint | Method | Throughput | p99 | Errors | +| -------- | ------ | ---------- | --- | ------ | +| `/api/v1/circuit-breaker` (closed) | GET | ~625 KB/s (≈7.0k rps) | 9 ms | 0 | +| `/api/v1/circuit-breaker/rejected` (open) | GET | ~750 KB/s (≈7.6k rps) | 8 ms | 0 | + +The rejected path is intentionally the fast-fail 503 flow (all responses are +`non2xx` by design) and is the fastest route — showing that an open breaker adds +negligible overhead while protecting downstream resources. diff --git a/backend/docs/CORS_GUIDE.md b/backend/docs/CORS_GUIDE.md new file mode 100644 index 00000000..0a5ca133 --- /dev/null +++ b/backend/docs/CORS_GUIDE.md @@ -0,0 +1,163 @@ +# CORS Policy Management with Dynamic Origin Whitelisting + +Express CORS enforcement backed by a **runtime-mutable origin allowlist**. The +allowlist can be changed at any time (admin API, code, or an async loader) +and the new policy applies to the very next request — **no redeploy required**. + +Files: + +- `backend/src/middleware/cors.ts` — `createCorsMiddleware()` / `cors()` +- `backend/src/services/cors.ts` — `CORSOriginPolicy` + shared singleton +- `backend/src/routes/cors.ts` — `corsRouter` management endpoints + +## Supported allowlist patterns + +| Pattern | Matches | +| ---------------------------- | -------------------------------------------------------------- | +| `https://app.example.com` | Exactly that origin (scheme + host, port ignored) | +| `https://*.example.com` | `example.com` and every subdomain, HTTPS only | +| `*.example.com` | `example.com` and every subdomain, any scheme | +| `example.com` | `example.com` only, any scheme | +| `*` | Any origin (open mode) | + +Matching is case-insensitive and ignores the port. `null` and empty origins +(sandboxed/`file://` browsers) are always denied. Syntax is validated on +write: origins with paths, queries, fragments, whitespace, or control +characters are rejected with `INVALID_CORS_ORIGIN`. + +## Bootstrapping + +The app seeds the shared policy from `CORS_ALLOWED_ORIGINS` at startup and +mounts the middleware globally: + +```ts +initCorsPolicy({ + allowedOrigins: config.cors.allowedOrigins, // CSV from env + allowCredentials: true, +}); + +app.use( + createCorsMiddleware({ + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Trace-Id', REQUEST_ID_HEADER], + }) +); +``` + +## Service API (`services/cors.ts`) + +```ts +import { + CORSOriginPolicy, + initCorsPolicy, + getCorsPolicy, + addAllowedOrigin, // (pattern) => size + removeAllowedOrigin, // (pattern) => boolean + setAllowedOrigins, // (patterns: string[]) — atomic, throws on bad entry + getAllowedOrigins, // () => string[] + isOriginAllowed, // (origin) => boolean + refreshAllowedOrigins, // () => Promise + getCorsMetrics, // () => CorsMetrics + resetCorsMetrics, + originMatches, isValidOrigin, isValidPattern, +} from '../services/cors.js'; + +const policy = getCorsPolicy(); +policy.add('https://dashboard.example.com'); // live on the next request +policy.remove('https://legacy.example.com'); +policy.setCredentials(false); // stop reflecting credentials +``` + +`CorsPolicyOptions.loader` plugs in an async source of truth so the allowlist +can be kept in sync with a database or config service: + +```ts +initCorsPolicy({ + loader: async () => (await db.corsAllowlist.findMany()).map((r) => r.origin), +}); +await refreshAllowedOrigins(); // re-pull; fails safe — the old list is kept +``` + +## Middleware behaviour + +| Request | Response | +| -------------------------------------------------- | ----------------------------------------------------- | +| No `Origin` header | Passthrough; `Vary: Origin` | +| Allowed origin (simple GET/POST/…) | Reflects the origin (`Access-Control-Allow-Origin`), `Vary: Origin` | +| Denied origin | Passthrough with **no** CORS headers (browser blocks) | +| Preflight (`OPTIONS` + `Access-Control-Request-Method`) | Answered with `204` + negotiated headers (or handed to the app with `preflightContinue`) | + +Open mode (`*`) plus credentials never emits a bare +`Access-Control-Allow-Origin: *` with `Access-Control-Allow-Credentials` — +browsers reject that combination — the concrete origin is reflected instead. + +## Management endpoints (`/api/v1/cors`) + +| Method | Path | Body / Params | +| ----------- | --------------- | ------------------------------------------------------ | +| `GET` | `/config` | Policy: origins, wildcard, credentials, version, metrics | +| `PUT` | `/config` | `{ allowedOrigins, allowCredentials? }` (atomic replace) | +| `GET` | `/origins` | Current allowlist | +| `POST` | `/origins` | `{ origin }` → adds one entry | +| `DELETE` | `/origins?origin=…` | Removes one entry | +| `POST` | `/refresh` | Re-pull the allowlist from the loader | + +```bash +curl -X POST localhost:3001/api/v1/cors/origins \ + -H 'content-type: application/json' \ + -d '{"origin":"https://dashboard.example.com"}' + +curl -X PUT localhost:3001/api/v1/cors/config \ + -H 'content-type: application/json' \ + -d '{"allowedOrigins":["https://app.example.com","https://*.tenant.example.com"]}' +``` + +Invalid input returns `400`; the current allowlist is never partially +mutated. + +## Security notes + +- Denied origins are passed through **without** CORS headers. The browser + enforces the block — the API never needs to reject cross-origin requests + itself, and proxied requests still work. +- The `corsRouter` mutates cross-origin policy for the whole API. In + production mount it behind the API-key / ACL middleware (it follows the + same convention as `ip-allowlistRouter` in this codebase). +- Keep `allowCredentials` disabled unless you use cookie/session auth, and + prefer explicit origins over `*` in production. + +## Performance benchmarks + +Run from `backend/`: + +```sh +npm run benchmark:baseline # regenerate benchmarks/baseline.json +npm run benchmark +npm run benchmark:compare # fail if p99 regressed >10% +``` + +The suite benchmarks the middleware resolving an exact origin +(`cors_allowed`), a wildcard tenant pattern (`cors_wildcard`), and a fully +negotiated preflight (`cors_preflight`) against the same logical payload. +Typical results on local hardware: exact-origin reflection ≈ 1 900 rps +(p99 ≈ 12 ms), preflight ≈ 2 800 rps (p99 ≈ 8 ms), 0 errors. + +## Testing + +```sh +npx vitest run src/services/__tests__/cors.test.ts \ + src/middleware/__tests__/cors.test.ts \ + src/middleware/__tests__/cors.integration.test.ts + +npx vitest run --coverage \ + --coverage.include='src/services/cors.ts' \ + --coverage.include='src/middleware/cors.ts' \ + --coverage.include='src/routes/cors.ts' \ + src/services/__tests__/cors.test.ts \ + src/middleware/__tests__/cors.test.ts \ + src/middleware/__tests__/cors.integration.test.ts +``` + +Coverage: service ≈ 98% statements/100% lines, middleware ≈ 98%, +management router ≈ 96% — all comfortably above the 80% gate. \ No newline at end of file diff --git a/backend/docs/RESPONSE_CACHING.md b/backend/docs/RESPONSE_CACHING.md new file mode 100644 index 00000000..6db68aeb --- /dev/null +++ b/backend/docs/RESPONSE_CACHING.md @@ -0,0 +1,136 @@ +# API Response Caching with ETags + +Two composable Express middlewares provide HTTP caching for read-heavy +endpoints: **`etag()`** (response tagging + conditional requests) and +**`cacheControl()`** (cache headers, optional in-memory storage, and +stale-while-revalidate). They are implemented in +`backend/src/middleware/etag.ts` and `backend/src/middleware/cache.ts`. + +Use `etag()` for bandwidth savings on any GET/HEAD endpoint. Use +`cacheControl()` when a response body is stable enough to be stored and served +without re-running the handler. + +## ETag middleware (`etag()`) + +Generates an ETag from the serialised response body and answers matching +`If-None-Match` requests with a `304 Not Modified` (weak comparison, RFC 7232). + +```ts +import { etag } from '../middleware/etag.js'; + +router.get('/api/v1/payments/:id', etag(), handler); +``` + +Options: + +| Option | Default | Effect | +| ---------------------- | ---------------------- | ----------------------------------------------- | +| `algorithm` | `sha256` | Hash algorithm (`sha256`, `sha1`, `md5`) | +| `weak` | `false` | Always emit a weak ETag (`W/"…"`) | +| `weakThresholdBytes` | `1024` | Emit a weak ETag above this body size | +| `maxBodySize` | `1 MiB` | Skip ETag logic for larger bodies | +| `bypassAuthenticated` | `false` | Skip requests with `authorization`/`x-api-key` | + +Behaviour: + +- Only GET/HEAD are processed; mutations pass straight through. +- Responses with `statusCode >= 400` are never tagged. +- An ETag already set by another middleware is honoured (no duplicate tags). +- A client `If-None-Match` of `"*"` always matches. +- Metrics are exposed via `getETagMetrics()` / `resetETagMetrics()`. + +## Cache middleware (`cacheControl()`) + +```ts +import { cacheControl, CacheTTL } from '../middleware/cache.js'; + +// Header-only mode: Cache-Control + ETag, no storage. Fast, zero risk. +router.get('/api/v1/customer/:id', cacheControl({ maxAge: CacheTTL.SHORT }), handler); + +// In-memory mode: store the body, serve subsequent requests straight from memory. +router.get('/api/v1/catalog', cacheControl({ + maxAge: CacheTTL.STATIC, + inMemory: true, + staleWhileRevalidate: 60, +}), handler); +``` + +| Option | Default | Effect | +| --------------------- | ------------ | --------------------------------------------------------- | +| `maxAge` | *required* | Cache lifetime in seconds (`CacheTTL.*` presets provided) | +| `isPublic` | `true` | Emit `private` instead of `public` | +| `inMemory` | `false` | Store responses in memory and serve them directly | +| `staleWhileRevalidate`| `undefined` | Serve stale content for up to N seconds behind a re-fetch | +| `cacheKey` | *method+URL* | Fixed cache-key override | +| `maxBodySize` | `1 MiB` | Do not cache bodies larger than this | + +`CacheTTL` presets: `STATIC=300`, `SHORT=30`, `IMMUTABLE=600`, `LONG=3600`, +`NONE=0`. + +### Response semantics + +- `X-Cache: MISS` — handler ran, response stored on completion. +- `X-Cache: HIT` — body served from memory without touching the handler. +- `X-Cache: STALE` — stale body served because `staleWhileRevalidate > 0`. +- `Cache-Control: no-store` — emitted for responses with `statusCode >= 400` + (nothing is cached, no ETag is attached). Mutations pass through uncached. +- A matching `If-None-Match` yields a `304 Not Modified` from stored entries. + +Responses are stored only after the socket `finish` event, so interrupted +responses never poison the cache. + +### Warming & invalidation + +```ts +import { warmCache, invalidateCache, clearMemoryCache } from '../middleware/cache.js'; + +warmCache('agenticpay:cache:GET:/api/v1/catalog', fetchCatalog, CacheTTL.LONG * 1000); +await invalidateCache('GET:/api/v1/catalog*'); // glob against the internal prefix +``` + +`getMemoryCache()` exposes the in-memory store (with `keys()`, `delete()`, +`clear()`), and `getCacheMonitor()` reports hits/misses/sets metrics. When +`REDIS_URL` is set, stored responses are mirrored to Redis; without it every +Redis operation becomes a safe no-op. + +## Composition + +Do **not** stack `etag()` and `cacheControl()` on the same route — `cacheControl` +already attaches an ETag and performs its own conditional-request handling. +Pick one strategy per route: + +- `etag()` — the server still runs the handler each request, but sends a `304` + to clients holding the current representation. +- `cacheControl({ inMemory: true })` — the handler runs only on a miss. +- `cacheControl({ maxAge })` — header-only; useful for CDN/browser caching of + public, stable resources. + +## Benchmarks + +Run from `backend/`: + +```sh +npm run benchmark:baseline # regenerate benchmarks/baseline.json +npm run benchmark # write results.json +npm run benchmark:compare # fail if p99 regressed >10% +``` + +The suite includes `cache_plain`, `cache_header_only`, `cache_memory_hit` and +`cache_etag_304` endpoints measuring each strategy side by side against the same +logical payload. + +## Testing + +Unit and integration coverage lives in: + +- `backend/src/middleware/__tests__/etag.test.ts` +- `backend/src/middleware/__tests__/cache.test.ts` +- `backend/src/middleware/__tests__/etag-cache.integration.test.ts` (real HTTP) + +```sh +npx vitest run --coverage --coverage.include='src/middleware/{etag,cache}.ts' \ + src/middleware/__tests__/etag.test.ts src/middleware/__tests__/cache.test.ts +``` + +Both middleware files hold >90% statement and line coverage and >90% branch +coverage. \ No newline at end of file diff --git a/backend/docs/WEBHOOK_KEY_ROTATION.md b/backend/docs/WEBHOOK_KEY_ROTATION.md new file mode 100644 index 00000000..7ebf5cb3 --- /dev/null +++ b/backend/docs/WEBHOOK_KEY_ROTATION.md @@ -0,0 +1,174 @@ +# Webhook Signature Verification with Key Rotation + +Two cooperating modules implement inbound webhook signature verification backed by a +versioned, rotating key registry: + +- `src/services/webhookKeys.ts` — the key registry: lifecycle, rotation, revocation, + retention, signing and constant-time verification with metrics. +- `src/middleware/webhookVerification.ts` — Express middleware that verifies inbound + `custom` webhooks against the registry (rotation-aware), falling back to the legacy + per-provider secrets when no keys are registered. + +## Signature scheme + +Messages are covered by an HMAC-SHA256 digest over `"."` (the same +message format used by `services/webhooks/signer.ts`): + +``` +digest = HMAC-SHA256(secret, `${timestampSeconds}.${rawBody}`) +``` + +Accepted signature header values (normalized before comparison): + +| Form | Example | +| ----------------------------- | -------------------------------------------------------------- | +| bare v1 | `v1=a1b2…` (64 hex chars) | +| v1 + embedded keyId | `v1=wvk_custom_m8p0_9f2c.a1b2…` | +| legacy prefix | `sha256=a1b2…` / `sig-sha256=a1b2…` / `sha256-a1b2…` | +| bare hex (no prefix) | `a1b2…` | + +Embedding the `keyId` in the signature string keeps verification unambiguous across +rotations even when the caller does not send a separate `keyId` header. + +## Key lifecycle + +``` +register ──> active + │ rotate() + ├── retiredAt = now + ├── expiresAt = now + overlap (still VERIFIES during overlap) + v + retiring ▸ expire ▸ purge after retention + ▲ + │ revoke(keyId) + └── revoked (fails immediately, purged after retention) +``` + +- **Exactly one active key per provider** is created by `rotate()`. Previous active keys + become `retiring` and remain valid for verification for `overlapSeconds` (default 72 h) + so inflight deliveries signed with the old key are not rejected. +- `revoke(keyId)` immediately invalidates a key (emergency). `revoke` refuses the last + active key for a provider — rotate first. +- `purgeExpired()` removes retired/revoked keys past `retentionSeconds` (default 7 d) and + fully-expired active keys; it runs as part of `rotate()` to bound memory growth. +- Keys are per-provider; an optional `~expiresAt` may be set on any key. + +## `services/webhookKeys.ts` API + +```ts +const registry = getWebhookKeyRegistry(); // shared singleton + +registry.register({ provider: 'custom', secret }); // or let it generate a secret +const { retired, active } = registry.rotate({ provider: 'custom' }); +registry.revoke(keyId); + +const signed = registry.sign({ provider: 'custom', body: rawBody }); +// { signature: 'v1=wvk_custom_….hex', timestamp: '1700000000', keyId, version: 'v1' } + +const result = registry.verify({ + signature, timestamp, body: rawBody, provider: 'custom', +}); +// { isValid, keyId?, timestamp, ageMs, error?, reason? } +``` + +`reason` on a failed verification is one of: `missing_signature`, +`invalid_signature_format`, `missing_timestamp`, `timestamp_out_of_tolerance`, +`no_keys`, `unknown_key`, `key_revoked`, `key_expired`, `signature_mismatch`. + +- Timestamps may be seconds, milliseconds, or ISO 8601 strings. +- The replay window defaults to 300 s (set via `toleranceSeconds` on the registry or + `configureWebhookVerification({ toleranceSeconds })`). +- Overlap/retention/tolerance are configurable per instance + (`new WebhookKeyRegistry({ now, overlapSeconds, retentionSeconds, toleranceSeconds, keys })`); + `now` is injectable for deterministic rotation tests. +- `sign()` refuses non-active keys (`WEBHOOK_KEY_NOT_ACTIVE`) and unregistered keyIds. +- Metrics via `registry.metrics()`: counts of register/rotate/revoke/purge/sign/verify, + plus per-reason rejection counters. `resetMetrics()` resets counters only. + +## Middleware integration + +`verifyCustomProviderWebhook` (`webhookVerifiers.custom`) now routes through +`verifyCustomProviderWebhookWithKeys`: + +1. Registry keys exist + a signature and timestamp header are present → verify against the + registry (rotation-aware, keyId-aware). +2. Otherwise → legacy `verifyWebhookSignature` path (single-secret map in + `services/webhooks/verification.ts`), preserving existing behavior when the registry is + not configured. + +Signature/timestamp headers are read from either convention: + +| Role | AgenticPay outbound | Third-party custom | +| --------- | -------------------------------------- | ----------------------------------- | +| signature | `X-AgenticPay-Signature` | `X-Signature` | +| timestamp | `X-AgenticPay-Timestamp` | `X-Timestamp` | +| keyId | `X-Webhook-Key-Id` (optional) | `X-Webhook-Key-Id` (optional) | + +Failure surfaces as `401 WEBHOOK_VERIFICATION_FAILED`; duplicate event deliveries surface +as `409 WEBHOOK_REPLAY` (event ID from `X-Webhook-Id`). + +Configuration: + +```ts +import { configureWebhookVerification, resetWebhookVerificationConfig } from '../middleware/index.js'; + +configureWebhookVerification({ useKeyRotation: true, toleranceSeconds: 300 }); +``` + +`middleware/index.ts` re-exports the real names (`verifyWebhookProvider`, +`webhookVerifiers`, `captureRawBody`, `webhookJsonParser`, `configureWebhookVerification`, +and `WebhookVerificationConfig`). + +## Rotating a key at runtime + +```ts +const registry = getWebhookKeyRegistry(); +const { active } = registry.rotate({ provider: 'custom', secret: 'new_32+_char_secret' }); + +// Share the new secret with the sender. Old signatures keep working until the +// 72 h overlap elapses; then rotate again or revoke the prior key. +``` + +## Security notes + +- Comparison uses `timingSafeEqual` on decoded digests (constant time). +- `parseWebhookSignature` rejects malformed/hostile header values before hashing. +- Timestamp tolerance prevents replay within 300 s; `isReplayEvent` in the middleware + dedupes event IDs (5 min TTL) — swap the in-memory dedupe for Redis in multi-instance + deploys. +- `sign()` never signs with retiring/revoked keys, so a rotated key cannot be resurrected + through an intermediary. +- Secrets are generated with `crypto.randomBytes(32)` (base64url) when not supplied. +- The registry is intentionally dependency-free (no errorHandler/logger), so it can be + loaded in any context including tests. + +## Testing + +```bash +npx vitest run src/services/__tests__/webhookKeys.test.ts # registry + rotation (41) +npx vitest run src/middleware/__tests__/webhookVerification.test.ts # middleware paths (8) +npx vitest run src/middleware/__tests__/webhookVerification.integration.test.ts # real HTTP (7) +npx vitest run src/middleware/__tests__/webhookVerification.dispatcher.test.ts # dispatcher paths (7) +``` + +Coverage (v8, scoped to `webhookKeys.ts` + `webhookVerification.ts` via +`--coverage.include`): statements 95.4%, branches 87.9%, functions 95.8%, lines 96.3% +(all ≥80% threshold). + +## Performance + +Endpoints added to the benchmark harness: + +| Endpoint | Method | Notes | +| ---------------------------- | ------ | -------------------------------------------- | +| `/api/v1/webhook/verify` | POST | Valid HMAC signature → 200 | +| `/api/v1/webhook/verify-invalid` | POST | Constant-time rejection path → 401 | + +`npm run benchmark:baseline` regenerates `benchmarks/baseline.json`. Results on this host: + +| Endpoint | ~RPS | p99 | Errors | +| --- | --- | --- | --- | +| valid | ~1.7k | 14 ms | 0 | +| invalid | ~1.6k | 13 ms | 0 (401s expected) | + +`npm run benchmark:compare` fails CI only if p99 regresses beyond the configured ratio. \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 69974ffe..b2aab090 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,7 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { randomUUID } from 'node:crypto'; import express, { Request, Response, NextFunction } from 'express'; -import cors from 'cors'; import dotenv from 'dotenv'; import rateLimit from 'express-rate-limit'; import compression from 'compression'; @@ -34,6 +33,9 @@ import { stripeRouter } from './routes/stripe.js'; import { ipAllowlistMiddleware, initIpAllowlist } from './middleware/ip-allowlist.js'; import { SecurityMiddleware, SecurityMonitor, securityHeadersMiddleware } from './middleware/security.js'; import { sanitizeInput, contentSecurityPolicy } from './middleware/sanitize.js'; +import { createCorsMiddleware } from './middleware/cors.js'; +import { initCorsPolicy } from './services/cors.js'; +import { corsRouter } from './routes/cors.js'; import { notificationsRouter } from './routes/notifications.js'; import { auditRouter } from './routes/audit.js'; import { taxReportingRouter } from './routes/tax-reporting.js'; @@ -166,9 +168,15 @@ const invoiceLimiter = rateLimit({ }); app.use(securityHeadersMiddleware()); + +// Dynamic CORS policy: seed the allowlist from config, then serve every +// request through the shared CORSOriginPolicy (runtime-mutable). +initCorsPolicy({ + allowedOrigins: config.cors.allowedOrigins, + allowCredentials: true, +}); app.use( - cors({ - origin: config.cors.allowedOrigins, + createCorsMiddleware({ credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Trace-Id', REQUEST_ID_HEADER], @@ -277,6 +285,8 @@ apiV1Router.use('/backup', backupRouter); apiV1Router.use('/audit', auditRouter); // IP allowlist management apiV1Router.use('/ip-allowlist', ipAllowlistRouter); +// Dynamic CORS policy management +apiV1Router.use('/cors', corsRouter); // Push notifications apiV1Router.use('/push', pushRouter); // Stripe card payments diff --git a/backend/src/middleware/__tests__/cache.test.ts b/backend/src/middleware/__tests__/cache.test.ts index 6f832db6..c7eb9d44 100644 --- a/backend/src/middleware/__tests__/cache.test.ts +++ b/backend/src/middleware/__tests__/cache.test.ts @@ -1,12 +1,26 @@ /** * cache.test.ts * - * Unit tests for the cacheControl() middleware and CacheTTL constants. + * Unit tests for the cache module: CacheTTL constants, MemoryCache, + * SingleFlight, CacheMonitor, warmCache/invalidation helpers and the + * cacheControl() middleware (header-only + in-memory modes). */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; import type { Request, Response } from 'express'; -import { cacheControl, CacheTTL } from '../cache.js'; +import { + cacheControl, + CacheTTL, + getCacheMonitor, + getRedisCache, + getMemoryCache, + getSingleFlight, + warmCache, + getWarmedKeys, + invalidateCache, + clearMemoryCache, +} from '../cache.js'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -14,27 +28,35 @@ function makeReq(overrides: Partial = {}): Request { return { method: 'GET', headers: {}, + originalUrl: '/api/test', ...overrides, } as unknown as Request; } function makeRes(): { - res: Response; + res: Response & EventEmitter; headers: Record; sentStatus: number | null; sentBody: unknown; jsonCalled: boolean; + emitFinish: () => void; } { const headers: Record = {}; let sentStatus: number | null = null; let sentBody: unknown = undefined; let jsonCalled = false; - const res = { + const emitter = new EventEmitter(); + const res = Object.assign(emitter, { + statusCode: 200, setHeader: vi.fn((name: string, value: string | number) => { headers[name] = value; }), + getHeader: vi.fn((name: string): string | number | undefined => { + return headers[name]; + }), status: vi.fn(function (code: number) { + res.statusCode = code; sentStatus = code; return res; }), @@ -44,36 +66,312 @@ function makeRes(): { sentBody = body; return res; }), - } as unknown as Response; + }) as unknown as Response & EventEmitter; + + // Vitest mocks wipe `on`; re-add the EventEmitter binding for 'finish'. + res.on = emitter.on.bind(emitter); - return { res, headers, get sentStatus() { return sentStatus; }, get sentBody() { return sentBody; }, get jsonCalled() { return jsonCalled; } }; + return { + res, + headers, + get sentStatus() { return sentStatus; }, + get sentBody() { return sentBody; }, + get jsonCalled() { return jsonCalled; }, + emitFinish: () => emitter.emit('finish'), + }; } -// ─── Tests ──────────────────────────────────────────────────────────────────── +beforeEach(() => { + clearMemoryCache(); + getCacheMonitor().reset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +// ─── CacheTTL constants ─────────────────────────────────────────────────────── describe('CacheTTL constants', () => { - it('STATIC is 300 seconds', () => expect(CacheTTL.STATIC).toBe(300)); - it('SHORT is 30 seconds', () => expect(CacheTTL.SHORT).toBe(30)); - it('IMMUTABLE is 600 seconds', () => expect(CacheTTL.IMMUTABLE).toBe(600)); - it('NONE is 0', () => expect(CacheTTL.NONE).toBe(0)); + it('exposes the full TTL ladder', () => { + expect(CacheTTL.STATIC).toBe(300); + expect(CacheTTL.SHORT).toBe(30); + expect(CacheTTL.IMMUTABLE).toBe(600); + expect(CacheTTL.LONG).toBe(3600); + expect(CacheTTL.NONE).toBe(0); + }); +}); + +// ─── MemoryCache ────────────────────────────────────────────────────────────── + +describe('MemoryCache', () => { + it('round-trips values and reports them fresh', () => { + const cache = getMemoryCache(); + cache.set('a:1', { hello: 'world' }, 60_000, '"etag"'); + const got = cache.get<{ hello: string }>('a:1'); + expect(got).not.toBeNull(); + expect(got?.value).toEqual({ hello: 'world' }); + expect(got?.stale).toBe(false); + expect(got?.etag).toBe('"etag"'); + expect(cache.size).toBe(1); + }); + + it('returns null for unknown keys', () => { + expect(getMemoryCache().get('missing')).toBeNull(); + }); + + it('reports entries as stale once expired', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(1_000_000); + const cache = getMemoryCache(); + cache.set('t:1', { v: 1 }, 1000); + expect(cache.get('t:1')?.stale).toBe(false); + + vi.setSystemTime(1_001_001); + expect(cache.get('t:1')?.stale).toBe(true); + }); + + it('increments hitCount on every read', () => { + const cache = getMemoryCache(); + cache.set('h:1', 1, 60_000); + cache.get('h:1'); + cache.get('h:1'); + expect(cache.getStats().totalHits).toBe(2); + }); + + it('evicts only expired entries', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(5_000_000); + const cache = getMemoryCache(); + cache.set('expired', 1, 100); + cache.set('alive', 2, 1000); + + vi.setSystemTime(5_000_200); + const evicted = cache.evictExpired(); + + expect(evicted).toBe(1); + expect(cache.has('expired')).toBe(false); + expect(cache.has('alive')).toBe(true); + }); + + it('deletes individual keys', () => { + const cache = getMemoryCache(); + cache.set('d:1', 1, 60_000); + cache.delete('d:1'); + expect(cache.has('d:1')).toBe(false); + }); + + it('clears the store', () => { + const cache = getMemoryCache(); + cache.set('c:1', 1, 60_000); + cache.set('c:2', 2, 60_000); + cache.clear(); + expect(cache.size).toBe(0); + }); + + it('evicts the oldest entry past its capacity', () => { + const cache = getMemoryCache(); + const maxSize = cache.getStats().maxSize; + cache.set('first', 'a', 60_000); + for (let i = 0; i < maxSize; i++) { + cache.set(`filler-${i}`, i, 60_000); + } + expect(cache.size).toBe(maxSize); + expect(cache.has('first')).toBe(false); + expect(cache.has('filler-0')).toBe(true); + }); + + it('exposes keys and stats', () => { + const cache = getMemoryCache(); + cache.set('k:1', 1, 60_000); + cache.set('k:2', 2, 60_000); + expect([...cache.keys()].sort()).toEqual(['k:1', 'k:2']); + const stats = cache.getStats(); + expect(stats.totalEntries).toBe(2); + expect(stats.avgHitsPerEntry).toBe(0); + }); +}); + +// ─── SingleFlight ───────────────────────────────────────────────────────────── + +describe('SingleFlight', () => { + it('coalesces concurrent work for the same key', async () => { + const sf = getSingleFlight(); + let calls = 0; + const fn = async () => { + calls++; + await new Promise((r) => setTimeout(r, 5)); + return calls; + }; + + const [a, b, c] = await Promise.all([ + sf.execute('k', fn), + sf.execute('k', fn), + sf.execute('k', fn), + ]); + + expect(calls).toBe(1); + expect([a, b, c]).toEqual([1, 1, 1]); + expect(sf.inFlightCount).toBe(0); + }); + + it('runs different keys independently', async () => { + const sf = getSingleFlight(); + let calls = 0; + const fn = async () => ++calls; + + const [a, b] = await Promise.all([ + sf.execute('x', fn), + sf.execute('y', fn), + ]); + + expect(a).toBe(1); + expect(b).toBe(2); + }); + + it('clears the in-flight map even when the work rejects', async () => { + const sf = getSingleFlight(); + await expect( + sf.execute('boom', () => Promise.reject(new Error('nope'))), + ).rejects.toThrow('nope'); + expect(sf.inFlightCount).toBe(0); + }); }); -describe('cacheControl() middleware', () => { +// ─── CacheMonitor ───────────────────────────────────────────────────────────── + +describe('CacheMonitor', () => { + it('tracks hits, misses, sets and evictions', () => { + const monitor = getCacheMonitor(); + monitor.recordHit(); + monitor.recordHit(); + monitor.recordMiss(); + monitor.recordSet(); + monitor.recordEviction(); + + const stats = monitor.getStats(); + expect(stats.hits).toBe(2); + expect(stats.misses).toBe(1); + expect(stats.sets).toBe(1); + expect(stats.evictions).toBe(1); + }); + + it('computes a hit ratio', () => { + const monitor = getCacheMonitor(); + monitor.reset(); + monitor.recordHit(); + monitor.recordHit(); + monitor.recordMiss(); + expect(monitor.hitRatio).toBeCloseTo(2 / 3); + }); + + it('reset zeroes the counters', () => { + const monitor = getCacheMonitor(); + monitor.recordHit(); + monitor.reset(); + expect(monitor.hitRatio).toBe(0); + expect(monitor.getStats().hits).toBe(0); + }); + + it('empty monitor has a zero hit ratio', () => { + const monitor = getCacheMonitor(); + monitor.reset(); + expect(monitor.hitRatio).toBe(0); + }); +}); + +// ─── warmCache / invalidation helpers ───────────────────────────────────────── + +describe('warmCache / invalidation', () => { + it('warms a value into the memory cache', async () => { + warmCache('warm:1', async () => ({ data: 'hot' }), 60_000); + await vi.waitFor(() => { + expect(getMemoryCache().get('warm:1')).not.toBeNull(); + }); + expect(getMemoryCache().get<{ data: string }>('warm:1')?.value).toEqual({ data: 'hot' }); + expect(getWarmedKeys()).toContain('warm:1'); + }); + + it('does not warm the same key twice', async () => { + let calls = 0; + const fn = async () => { + calls++; + return { n: calls }; + }; + warmCache('warm:2', fn, 60_000); + warmCache('warm:2', fn, 60_000); + await vi.waitFor(() => { + expect(getMemoryCache().get('warm:2')).not.toBeNull(); + }); + expect(calls).toBe(1); + }); + + it('drops the key from the warmed set when the fetch fails', async () => { + warmCache('warm:fail', () => Promise.reject(new Error('nope')), 60_000); + await vi.waitFor(() => { + expect(getWarmedKeys()).not.toContain('warm:fail'); + }); + expect(getMemoryCache().has('warm:fail')).toBe(false); + }); + + it('computes an ETag when the warmed value is JSON-serialisable', async () => { + warmCache('warm:etag', async () => ({ ok: true }), 60_000); + await vi.waitFor(() => { + expect(getMemoryCache().get('warm:etag')).not.toBeNull(); + }); + expect(getMemoryCache().get('warm:etag')?.etag).toMatch(/^"[0-9a-f]{16}"$/); + }); + + it('falls back to an empty ETag when the warmed value is not serialisable', async () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + warmCache('warm:circ', async () => circular, 60_000); + await vi.waitFor(() => { + expect(getMemoryCache().has('warm:circ')).toBe(true); + }); + expect(getMemoryCache().get('warm:circ')?.etag).toBe(''); + }); + + it('invalidateCache removes matching keys and leaves others intact', async () => { + const cache = getMemoryCache(); + cache.set('agenticpay:cache:GET:/api/catalog', 1, 60_000); + cache.set('agenticpay:cache:GET:/api/other', 2, 60_000); + + await invalidateCache('GET:/api/catalog*'); + + expect(cache.has('agenticpay:cache:GET:/api/catalog')).toBe(false); + expect(cache.has('agenticpay:cache:GET:/api/other')).toBe(true); + }); + + it('clearMemoryCache empties the store and warmed keys', async () => { + getMemoryCache().set('x', 1, 60_000); + warmCache('warm:clear', async () => 1, 60_000); + await vi.waitFor(() => { + expect(getWarmedKeys()).toContain('warm:clear'); + }); + clearMemoryCache(); + expect(getMemoryCache().size).toBe(0); + expect(getWarmedKeys()).toEqual([]); + }); +}); + +// ─── cacheControl() middleware — header-only mode ───────────────────────────── + +describe('cacheControl() middleware (header-only)', () => { let next: ReturnType; beforeEach(() => { next = vi.fn(); }); - // ── Cache-Control header ──────────────────────────────────────────────────── - it('sets public Cache-Control with max-age for a normal GET', () => { const req = makeReq(); const { res, headers } = makeRes(); const mw = cacheControl({ maxAge: 300 }); mw(req, res, next); - (res.json as ReturnType)({ data: 1 }); + (res.json as unknown as ReturnType)({ data: 1 }); expect(headers['Cache-Control']).toBe('public, max-age=300'); expect(next).toHaveBeenCalledOnce(); @@ -82,10 +380,9 @@ describe('cacheControl() middleware', () => { it('sets private Cache-Control when isPublic is false', () => { const req = makeReq(); const { res, headers } = makeRes(); - const mw = cacheControl({ maxAge: 60, isPublic: false }); - mw(req, res, next); - (res.json as ReturnType)({ data: 1 }); + cacheControl({ maxAge: 60, isPublic: false })(req, res, next); + (res.json as unknown as ReturnType)({ data: 1 }); expect(headers['Cache-Control']).toBe('private, max-age=60'); }); @@ -93,10 +390,9 @@ describe('cacheControl() middleware', () => { it('appends stale-while-revalidate when provided', () => { const req = makeReq(); const { res, headers } = makeRes(); - const mw = cacheControl({ maxAge: 300, staleWhileRevalidate: 60 }); - mw(req, res, next); - (res.json as ReturnType)({ ok: true }); + cacheControl({ maxAge: 300, staleWhileRevalidate: 60 })(req, res, next); + (res.json as unknown as ReturnType)({ ok: true }); expect(headers['Cache-Control']).toBe('public, max-age=300, stale-while-revalidate=60'); }); @@ -104,92 +400,120 @@ describe('cacheControl() middleware', () => { it('sets no-store when maxAge is 0', () => { const req = makeReq(); const { res, headers } = makeRes(); - const mw = cacheControl({ maxAge: CacheTTL.NONE }); - mw(req, res, next); - (res.json as ReturnType)({}); + cacheControl({ maxAge: CacheTTL.NONE })(req, res, next); + (res.json as unknown as ReturnType)({}); expect(headers['Cache-Control']).toBe('no-store'); }); - // ── ETag header ───────────────────────────────────────────────────────────── - it('sets an ETag header on the response', () => { const req = makeReq(); const { res, headers } = makeRes(); - const mw = cacheControl({ maxAge: 30 }); - mw(req, res, next); - (res.json as ReturnType)({ value: 42 }); + cacheControl({ maxAge: 30 })(req, res, next); + (res.json as unknown as ReturnType)({ value: 42 }); expect(headers['ETag']).toMatch(/^"[0-9a-f]{16}"$/); }); - it('produces the same ETag for identical bodies', () => { - const body = { name: 'agenticpay', version: 1 }; - - const makeCall = () => { + it('produces the same ETag for identical bodies and different for different bodies', () => { + const call = (body: unknown) => { const req = makeReq(); const { res, headers } = makeRes(); - const mw = cacheControl({ maxAge: 60 }); - mw(req, res, next); - (res.json as ReturnType)(body); + cacheControl({ maxAge: 60 })(req, res, next); + (res.json as unknown as ReturnType)(body); return headers['ETag']; }; - expect(makeCall()).toBe(makeCall()); + const body = { name: 'agenticpay', version: 1 }; + expect(call(body)).toBe(call(body)); + expect(call({ a: 1 })).not.toBe(call({ a: 2 })); }); - it('produces different ETags for different bodies', () => { - const makeCall = (body: unknown) => { - const req = makeReq(); - const { res, headers } = makeRes(); - const mw = cacheControl({ maxAge: 60 }); - mw(req, res, next); - (res.json as ReturnType)(body); - return headers['ETag']; - }; + it('returns 304 when If-None-Match matches exactly', () => { + const body = { catalog: [] }; + const reqA = makeReq(); + const { res: resA, headers } = makeRes(); + cacheControl({ maxAge: 300 })(reqA, resA, next); + (resA.json as unknown as ReturnType)(body); + const etag = headers['ETag'] as string; + + const reqB = makeReq({ headers: { 'if-none-match': etag } }); + const { res: resB } = makeRes(); + cacheControl({ maxAge: 300 })(reqB, resB, next); + (resB.json as unknown as ReturnType)(body); + + expect((resB.status as unknown as ReturnType)).toHaveBeenCalledWith(304); + expect((resB.end as unknown as ReturnType)).toHaveBeenCalled(); + }); + + it('returns 304 when the client sends a weak variant of the ETag', () => { + const body = { x: 1 }; + const reqA = makeReq(); + const { res: resA, headers } = makeRes(); + cacheControl({ maxAge: 60 })(reqA, resA, next); + (resA.json as unknown as ReturnType)(body); + const etag = (headers['ETag'] as string).replace(/^"/, 'W/"'); + + const reqB = makeReq({ headers: { 'if-none-match': etag } }); + const { res: resB } = makeRes(); + cacheControl({ maxAge: 60 })(reqB, resB, next); + (resB.json as unknown as ReturnType)(body); - expect(makeCall({ a: 1 })).not.toBe(makeCall({ a: 2 })); + expect((resB.status as unknown as ReturnType)).toHaveBeenCalledWith(304); }); - // ── Conditional GET / 304 ─────────────────────────────────────────────────── + it('returns 304 for a wildcard If-None-Match (*)', () => { + const req = makeReq({ headers: { 'if-none-match': '*' } }); + const { res } = makeRes(); - it('returns 304 and skips body when If-None-Match matches the ETag', () => { - const body = { catalog: [] }; + cacheControl({ maxAge: 60 })(req, res, next); + (res.json as unknown as ReturnType)({ data: 1 }); - // First request — get the ETag + expect((res.status as unknown as ReturnType)).toHaveBeenCalledWith(304); + }); + + it('returns 304 when a comma-separated list contains a match', () => { + const body = { data: 'listed' }; const reqA = makeReq(); - const { res: resA, headers: headersA } = makeRes(); - cacheControl({ maxAge: 300 })(reqA, resA, vi.fn()); - (resA.json as ReturnType)(body); - const etag = headersA['ETag'] as string; + const { res: resA, headers } = makeRes(); + cacheControl({ maxAge: 60 })(reqA, resA, next); + (resA.json as unknown as ReturnType)(body); + const etag = headers['ETag'] as string; - // Second request — client sends the ETag back - const reqB = makeReq({ headers: { 'if-none-match': etag } as any }); - const { res: resB, headers: headersB } = makeRes(); - cacheControl({ maxAge: 300 })(reqB, resB, vi.fn()); - (resB.json as ReturnType)(body); + const reqB = makeReq({ headers: { 'if-none-match': `"old", ${etag}` } }); + const { res: resB } = makeRes(); + cacheControl({ maxAge: 60 })(reqB, resB, next); + (resB.json as unknown as ReturnType)(body); - expect((resB.status as ReturnType)).toHaveBeenCalledWith(304); - expect((resB.end as ReturnType)).toHaveBeenCalled(); - // ETag header should still be set even on 304 - expect(headersB['ETag']).toMatch(/^"[0-9a-f]{16}"$/); + expect((resB.status as unknown as ReturnType)).toHaveBeenCalledWith(304); }); it('does NOT return 304 when If-None-Match does not match', () => { - const req = makeReq({ headers: { 'if-none-match': '"outdatedETagValue"' } as any }); + const req = makeReq({ headers: { 'if-none-match': '"outdatedETagValue"' } }); const { res } = makeRes(); cacheControl({ maxAge: 60 })(req, res, next); - (res.json as ReturnType)({ updated: true }); + (res.json as unknown as ReturnType)({ updated: true }); - expect((res.status as ReturnType)).not.toHaveBeenCalledWith(304); + expect((res.status as unknown as ReturnType)).not.toHaveBeenCalledWith(304); }); - // ── Non-GET passthrough ───────────────────────────────────────────────────── + it('marks error responses as no-store and skips ETag logic', () => { + const req = makeReq(); + const { res, headers } = makeRes(); + (res.status as unknown as ReturnType)(503); + + cacheControl({ maxAge: 300 })(req, res, next); + (res.json as unknown as ReturnType)({ error: 'down' }); + + expect(headers['Cache-Control']).toBe('no-store'); + expect(headers['ETag']).toBeUndefined(); + expect((res.status as unknown as ReturnType)).not.toHaveBeenCalledWith(304); + }); - it('calls next() without modifying res.json for POST requests', () => { + it('passes POST requests straight through without touching res.json', () => { const req = makeReq({ method: 'POST' }); const { res } = makeRes(); const originalJson = res.json; @@ -197,11 +521,10 @@ describe('cacheControl() middleware', () => { cacheControl({ maxAge: 300 })(req, res, next); expect(next).toHaveBeenCalledOnce(); - // res.json should NOT have been replaced (POST is not intercepted) expect(res.json).toBe(originalJson); }); - it('calls next() without modifying res.json for DELETE requests', () => { + it('passes DELETE requests straight through', () => { const req = makeReq({ method: 'DELETE' }); const { res } = makeRes(); const originalJson = res.json; @@ -216,8 +539,266 @@ describe('cacheControl() middleware', () => { const { res, headers } = makeRes(); cacheControl({ maxAge: 120 })(req, res, next); - (res.json as ReturnType)({}); + (res.json as unknown as ReturnType)({}); expect(headers['Cache-Control']).toBe('public, max-age=120'); }); }); + +// ─── cacheControl() middleware — in-memory mode ─────────────────────────────── + +describe('cacheControl() middleware (in-memory)', () => { + let next: ReturnType; + + beforeEach(() => { + next = vi.fn(); + }); + + it('serves a MISS on the first request and stores the response on finish', () => { + const req = makeReq(); + const { res, headers, emitFinish } = makeRes(); + const mw = cacheControl({ maxAge: 300, inMemory: true }); + + mw(req, res, next); + (res.json as unknown as ReturnType)({ version: '1' }); + emitFinish(); + + expect(headers['X-Cache']).toBe('MISS'); + expect(headers['ETag']).toMatch(/^"[0-9a-f]{16}"$/); + expect(getMemoryCache().size).toBe(1); + expect(getCacheMonitor().getStats().sets).toBe(1); + }); + + it('serves cached values with X-Cache HIT and skips the handler', () => { + // Prime the cache like a previous request would have + const primeReq = makeReq(); + const prime = makeRes(); + cacheControl({ maxAge: 300, inMemory: true })(primeReq, prime.res, vi.fn()); + (prime.res.json as unknown as ReturnType)({ version: '1' }); + prime.emitFinish(); + + const req = makeReq(); + const hit = makeRes(); + const mw = cacheControl({ maxAge: 300, inMemory: true }); + + mw(req, hit.res, next); + + expect(hit.headers['X-Cache']).toBe('HIT'); + expect(hit.headers['Cache-Control']).toBe('public, max-age=300'); + expect(hit.headers['ETag']).toMatch(/^"[0-9a-f]{16}"$/); + expect(hit.sentBody).toEqual({ version: '1' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 304 for a matching If-None-Match when serving from cache', () => { + const primeReq = makeReq(); + const prime = makeRes(); + cacheControl({ maxAge: 300, inMemory: true })(primeReq, prime.res, vi.fn()); + (prime.res.json as unknown as ReturnType)({ version: '1' }); + prime.emitFinish(); + const etag = prime.headers['ETag'] as string; + + const req = makeReq({ headers: { 'if-none-match': etag } }); + const hit = makeRes(); + cacheControl({ maxAge: 300, inMemory: true })(req, hit.res, next); + + expect((hit.res.status as unknown as ReturnType)).toHaveBeenCalledWith(304); + expect((hit.res.end as unknown as ReturnType)).toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('serves stale content with X-Cache STALE when stale-while-revalidate > 0', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(2_000_000); + const body = { version: 'old' }; + + const primeReq = makeReq(); + const prime = makeRes(); + cacheControl({ maxAge: 300, inMemory: true, staleWhileRevalidate: 60 })( + primeReq, + prime.res, + vi.fn(), + ); + (prime.res.json as unknown as ReturnType)(body); + prime.emitFinish(); + + vi.setSystemTime(2_000_000 + 300 * 1000 + 1); + + const req = makeReq(); + const hit = makeRes(); + cacheControl({ maxAge: 300, inMemory: true, staleWhileRevalidate: 60 })( + req, + hit.res, + next, + ); + + expect(hit.headers['X-Cache']).toBe('STALE'); + expect(hit.sentBody).toEqual(body); + }); + + it('re-runs the handler for stale entries when no SWR is configured', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(3_000_000); + + const primeReq = makeReq(); + const prime = makeRes(); + cacheControl({ maxAge: 1, inMemory: true })(primeReq, prime.res, vi.fn()); + (prime.res.json as unknown as ReturnType)({ version: 'old' }); + prime.emitFinish(); + + vi.setSystemTime(3_000_000 + 1_001); + + const req = makeReq(); + const hit = makeRes(); + cacheControl({ maxAge: 1, inMemory: true })(req, hit.res, next); + (hit.res.json as unknown as ReturnType)({ version: 'new' }); + hit.res.emit('finish'); + + expect(hit.headers['X-Cache']).toBe('MISS'); + expect(next).toHaveBeenCalledOnce(); + expect(getMemoryCache().get('agenticpay:cache:GET:/api/test')?.value).toEqual({ + version: 'new', + }); + }); + + it('returns 304 for a matching If-None-Match on an in-memory miss', () => { + const body = { data: 'conditional' }; + const reqA = makeReq(); + const resA = makeRes(); + cacheControl({ maxAge: 300, inMemory: true })(reqA, resA.res, next); + (resA.res.json as unknown as ReturnType)(body); + const etag = resA.headers['ETag'] as string; + + const reqB = makeReq({ headers: { 'if-none-match': etag } }); + const { res: resB } = makeRes(); + cacheControl({ maxAge: 300, inMemory: true })(reqB, resB, next); + (resB.json as unknown as ReturnType)(body); + + expect((resB.status as unknown as ReturnType)).toHaveBeenCalledWith(304); + }); + + it('does not cache error responses in in-memory mode', () => { + const req = makeReq(); + const { res, headers } = makeRes(); + (res.status as unknown as ReturnType)(500); + + cacheControl({ maxAge: 300, inMemory: true })(req, res, next); + (res.json as unknown as ReturnType)({ error: 'nope' }); + res.emit('finish'); + + expect(headers['X-Cache']).toBeUndefined(); + expect(headers['Cache-Control']).toBe('no-store'); + expect(getMemoryCache().size).toBe(0); + expect(getCacheMonitor().getStats().sets).toBe(0); + }); + + it('does not cache responses that exceed maxBodySize', () => { + const req = makeReq(); + const { res, headers } = makeRes(); + + cacheControl({ maxAge: 300, inMemory: true, maxBodySize: 8 })(req, res, next); + (res.json as unknown as ReturnType)({ payload: 'significantly-bigger' }); + res.emit('finish'); + + expect(headers['X-Cache']).toBeUndefined(); + expect(getMemoryCache().size).toBe(0); + }); + + it('honours a fixed cacheKey override', () => { + const req = makeReq(); + const { res, emitFinish } = makeRes(); + + cacheControl({ maxAge: 60, inMemory: true, cacheKey: 'shared!key' })(req, res, next); + (res.json as unknown as ReturnType)({ v: 1 }); + emitFinish(); + + expect(getMemoryCache().has('agenticpay:cache:shared!key')).toBe(true); + }); + + it('degrades gracefully when Redis is not configured', async () => { + const prevUrl = process.env.REDIS_URL; + delete process.env.REDIS_URL; + const redisCache = getRedisCache(); + + await redisCache.connect(); + expect(redisCache.isEnabled).toBe(false); + + // Every operation becomes a safe no-op when disabled. + await redisCache.set('k', { v: 1 }, 1000); + expect(await redisCache.get('k')).toBeNull(); + await redisCache.invalidate('*'); + await redisCache.invalidateAll(); + expect(await redisCache.getMemoryInfo()).toBeNull(); + + if (prevUrl !== undefined) process.env.REDIS_URL = prevUrl; + }); +}); + +// ─── RedisCache resilience (white-box: injected fake client) ───────────────── + +describe('RedisCache resilience', () => { + it('runs every operation against a fake client and degrades silently on failure', async () => { + const redisCache = getRedisCache(); + const internals = redisCache as unknown as { client: unknown; enabled: boolean }; + + const fake = { + get: vi.fn(async () => JSON.stringify({ v: 1 })), + setex: vi.fn(async () => 'OK'), + del: vi.fn(async () => 1), + keys: vi.fn(async () => ['agenticpay:cache:a']), + flushdb: vi.fn(async () => 'OK'), + info: vi.fn(async (section: string) => + section === 'memory' + ? 'used_memory_human:1.00M\nmaxmemory_human:256.00M\n' + : 'keyspace_hits:4\nkeyspace_misses:1\n', + ), + }; + + internals.enabled = true; + internals.client = fake; + + try { + await redisCache.set('k', { v: 1 }, 1); + expect(fake.setex).toHaveBeenCalledWith('k', 1, JSON.stringify({ v: 1 })); + expect(await redisCache.get('k')).toEqual({ v: 1 }); + + await redisCache.invalidate('GET:*'); + expect(fake.del).toHaveBeenCalledTimes(1); + + fake.keys.mockResolvedValueOnce([]); + await redisCache.invalidate('GET:none*'); + expect(fake.del).toHaveBeenCalledTimes(1); + + await redisCache.invalidateAll(); + expect(fake.flushdb).toHaveBeenCalled(); + + expect(await redisCache.getMemoryInfo()).toEqual({ + usedMemory: '1.00M', + maxMemory: '256.00M', + hitRatio: 0.8, + }); + + fake.info.mockImplementation(async (s: string) => (s === 'memory' + ? 'used_memory_human:2.00M\nmaxmemory_human:512.00M\n' + : '')); + expect((await redisCache.getMemoryInfo())?.hitRatio).toBe(0); + + fake.info.mockImplementation(async () => 'junk'); + const junk = await redisCache.getMemoryInfo(); + expect(junk?.usedMemory).toBe('?'); + expect(junk?.maxMemory).toBe('?'); + + fake.get.mockRejectedValueOnce(new Error('x')); + expect(await redisCache.get('k')).toBeNull(); + fake.info.mockImplementation(async () => { + throw new Error('x'); + }); + expect(await redisCache.getMemoryInfo()).toBeNull(); + fake.setex.mockRejectedValueOnce(new Error('x')); + await expect(redisCache.set('k', 1, 1)).resolves.toBeUndefined(); + } finally { + internals.client = null; + internals.enabled = false; + } + }); +}); \ No newline at end of file diff --git a/backend/src/middleware/__tests__/circuit-breaker.integration.test.ts b/backend/src/middleware/__tests__/circuit-breaker.integration.test.ts new file mode 100644 index 00000000..9c245454 --- /dev/null +++ b/backend/src/middleware/__tests__/circuit-breaker.integration.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { Server } from 'node:http'; +import express from 'express'; +import { circuitBreaker, resetAllCircuits, getCircuitState, circuitBreakerRegistry } from '../circuit-breaker.js'; + +let server: Server; +let base: string; + +// Stateful upstream that we can flip between healthy and failing. +let healthy = true; + +function setup() { + resetAllCircuits(); + healthy = true; + const app = express(); + + // A generic upstream guarded by a breaker with a short recovery window so the + // integration test can observe the full open -> half_open -> closed cycle. + app.use('/upstream', circuitBreaker('upstream-integration', { + failureThreshold: 3, + successThreshold: 2, + waitDurationInOpenState: 500, + permittedNumberOfCallsInHalfOpenState: 2, + requestTimeoutMs: 0, + })); + + app.get('/upstream/echo', (req, res) => { + if (!healthy) { + res.status(502).json({ error: 'bad gateway' }); + return; + } + res.json({ ok: true }); + }); + + return app; +} + +function get(path: string) { + return fetch(`${base}${path}`); +} + +describe('circuit breaker integration', () => { + beforeAll(async () => { + const app = setup(); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + if (address && typeof address === 'object') { + base = `http://127.0.0.1:${address.port}`; + } else { + throw new Error('Failed to bind test server'); + } + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + beforeEach(() => { + resetAllCircuits(); + healthy = true; + }); + + it('passes traffic while healthy and records successes', async () => { + const r1 = await get('/upstream/echo'); + expect(r1.status).toBe(200); + const r2 = await get('/upstream/echo'); + expect(r2.status).toBe(200); + const state = getCircuitState('upstream-integration'); + expect(state?.metrics.successfulCalls).toBe(2); + expect(state?.state).toBe('closed'); + }); + + it('opens the circuit after failures and rejects subsequent requests with 503', async () => { + healthy = false; + // Drive 3 consecutive failures (threshold = 3). + for (let i = 0; i < 3; i++) { + const r = await get('/upstream/echo'); + expect(r.status).toBe(502); + } + expect(getCircuitState('upstream-integration')?.state).toBe('open'); + + // Now the breaker short-circuits with 503 without hitting the upstream. + healthy = true; + const rejected = await get('/upstream/echo'); + expect(rejected.status).toBe(503); + const body = await rejected.json(); + expect(body.error.code).toBe('CIRCUIT_OPEN'); + + const state = getCircuitState('upstream-integration'); + expect(state?.metrics.rejectedCalls).toBeGreaterThan(0); + }); + + it('recovers to half_open and closes after enough successes', async () => { + healthy = false; + for (let i = 0; i < 3; i++) { + await get('/upstream/echo'); + } + expect(getCircuitState('upstream-integration')?.state).toBe('open'); + + // Bring the upstream back; wait for the recovery window then probe. + healthy = true; + await new Promise((r) => setTimeout(r, 600)); + + // First half-open probe passes, upstream healthy -> success. + const probe1 = await get('/upstream/echo'); + expect(probe1.status).toBe(200); + + // Second success closes the breaker. + const probe2 = await get('/upstream/echo'); + expect(probe2.status).toBe(200); + + expect(getCircuitState('upstream-integration')?.state).toBe('closed'); + }); + + it('re-opens quickly if the upstream is still failing during half-open', async () => { + healthy = false; + for (let i = 0; i < 3; i++) { + await get('/upstream/echo'); + } + expect(getCircuitState('upstream-integration')?.state).toBe('open'); + + // Wait for the recovery window; upstream still unhealthy. + await new Promise((r) => setTimeout(r, 600)); + // The first half-open probe is permitted and reaches the (still-failing) + // upstream, returning 502 and re-opening the breaker. + const probe = await get('/upstream/echo'); + expect(probe.status).toBe(502); + expect(getCircuitState('upstream-integration')?.state).toBe('open'); + + // A subsequent request is now short-circuited with 503. + const subsequent = await get('/upstream/echo'); + expect(subsequent.status).toBe(503); + expect(getCircuitState('upstream-integration')?.state).toBe('open'); + + // Confirms the breaker did not drift back to closed while the upstream was down. + expect(healthy).toBe(false); + }); + + it('registry is reachable via the module facade for Ops introspection', () => { + expect(circuitBreakerRegistry.names()).toContain('upstream-integration'); + const snap = circuitBreakerRegistry.getIfPresent('upstream-integration')?.snapshot(); + expect(snap?.name).toBe('upstream-integration'); + }); +}); diff --git a/backend/src/middleware/__tests__/circuit-breaker.test.ts b/backend/src/middleware/__tests__/circuit-breaker.test.ts new file mode 100644 index 00000000..cb4e4a99 --- /dev/null +++ b/backend/src/middleware/__tests__/circuit-breaker.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { getCircuitState, getAllCircuits, resetCircuit, resetAllCircuits } from '../circuit-breaker.js'; + +// Import the module namespace to reach the module-level singleton registry. +import * as cb from '../circuit-breaker.js'; + +function mockRes(statusCode = 200) { + const res = new EventEmitter() as any; + res.statusCode = statusCode; + res.headersSent = typeof statusCode !== 'undefined'; + res.status = (code: number) => { + res.statusCode = code; + return res; + }; + res.json = (body: unknown) => res.body = body; + return res; +} + +describe('circuit-breaker middleware facade (backward-compatible API)', () => { + beforeEach(() => { + resetAllCircuits(); + }); + + it('re-exports CircuitBreakerError and the registry singleton', () => { + expect(cb.CircuitBreakerError).toBeTypeOf('function'); + expect(cb.circuitBreakerRegistry).toBeDefined(); + }); + + it('withCircuitBreaker returns the resolved value on success', async () => { + const val = await cb.withCircuitBreaker('svc-a', async () => 123); + expect(val).toBe(123); + expect(getCircuitState('svc-a')?.metrics.successfulCalls).toBe(1); + }); + + it('withCircuitBreaker throws when the circuit opens and the breaker rejects', async () => { + const failer = async () => { + throw new Error('down'); + }; + // Open the circuit via consecutive failures using a low threshold. + for (let i = 0; i < 5; i++) { + await cb.withCircuitBreaker('svc-b', failer, undefined, { failureThreshold: 2 }).catch(() => undefined); + } + expect(getCircuitState('svc-b')?.state).toBe('open'); + await expect(cb.withCircuitBreaker('svc-b', async () => 'ok')).rejects.toBeInstanceOf(cb.CircuitBreakerError); + }); + + it('withCircuitBreaker uses the fallback when the circuit is open', async () => { + const failer = async () => { + throw new Error('down'); + }; + const fallback = vi.fn(async () => 'fallback'); + for (let i = 0; i < 5; i++) { + await cb.withCircuitBreaker('svc-c', failer, fallback, { failureThreshold: 2 }).catch(() => undefined); + } + const openResult = await cb.withCircuitBreaker('svc-c', async () => 'x', fallback); + expect(openResult).toBe('fallback'); + }); + + it('circuitBreaker middleware short-circuits an open circuit with 503', async () => { + const breaker = cb.circuitBreakerRegistry.get('mw-1', { failureThreshold: 2 }); + for (let i = 0; i < 4; i++) breaker.recordFailure(new Error(`f${i}`)); + expect(breaker.snapshot().state).toBe('open'); + + const mw = cb.circuitBreaker('mw-1'); + const req = {} as any; + const res = mockRes(); + const next = vi.fn(); + mw(req, res, next); + expect(res.statusCode).toBe(503); + expect(res.body.error.code).toBe('CIRCUIT_OPEN'); + expect(next).not.toHaveBeenCalled(); + }); + + it('circuitBreaker middleware runs next when the circuit is closed', () => { + const mw = cb.circuitBreaker('mw-2'); + const next = vi.fn(); + mw({} as any, mockRes(), next); + expect(next).toHaveBeenCalled(); + }); + + it('records success when the response finishes with 2xx', async () => { + const mw = cb.circuitBreaker('mw-3'); + const res = mockRes(200); + const next = vi.fn(); + mw({} as any, res, next); + expect(next).toHaveBeenCalled(); + res.emit('finish'); + const st = getCircuitState('mw-3'); + expect(st?.metrics.successfulCalls).toBe(1); + expect(st?.metrics.failedCalls).toBe(0); + }); + + it('records failure when the response finishes with 5xx', async () => { + const mw = cb.circuitBreaker('mw-4', { failureThreshold: 2 }); + const res = mockRes(503); + mw({} as any, res, vi.fn()); + res.emit('close'); + const st = getCircuitState('mw-4'); + expect(st?.metrics.failedCalls).toBe(1); + expect(st?.metrics.successfulCalls).toBe(0); + }); + + it('getAllCircuits returns serialisable snapshots', () => { + cb.withCircuitBreaker('svc-l1', async () => 1); + cb.withCircuitBreaker('svc-l2', async () => 2); + const all = getAllCircuits(); + expect(all.map((s) => s.name)).toContain('svc-l1'); + expect(all.map((s) => s.name)).toContain('svc-l2'); + // Snapshot is JSON-serialisable. + expect(() => JSON.stringify(all)).not.toThrow(); + }); + + it('getCircuitState returns null for unknown circuits', () => { + expect(getCircuitState('does-not-exist')).toBeNull(); + }); + + it('resetCircuit returns true for known and false for unknown', () => { + cb.withCircuitBreaker('svc-r', async () => 1); + expect(resetCircuit('svc-r')).toBe(true); + expect(resetCircuit('nope')).toBe(false); + }); + + it('resetAllCircuits resets everything and getAllCircuits reflects it', async () => { + const failer = async () => { + throw new Error('down'); + }; + for (let i = 0; i < 5; i++) { + await cb.withCircuitBreaker('svc-z', failer, undefined, { failureThreshold: 2 }).catch(() => undefined); + } + expect(getCircuitState('svc-z')?.state).toBe('open'); + resetAllCircuits(); + expect(getCircuitState('svc-z')?.state).toBe('closed'); + }); + + it('circuitBroken wraps a handler and records its resolved outcome', async () => { + const handler = (_req: any, res: any) => { + res.statusCode = 200; + }; + const wrapped = cb.circuitBroken('wrapped-1', handler as any); + const res = mockRes(200); + await wrapped({} as any, res, vi.fn()); + // resolution is recorded via the promise chain + await new Promise((r) => setImmediate(r)); + expect(getCircuitState('wrapped-1')?.metrics.successfulCalls).toBe(1); + }); + + it('circuitBroken records a failure and forwards the error when the handler throws', async () => { + const handler = () => { + throw new Error('handler boom'); + }; + const wrapped = cb.circuitBroken('wrapped-2', handler as any); + const next = vi.fn(); + await wrapped({} as any, mockRes(500), next); + await new Promise((r) => setImmediate(r)); + expect(next).toHaveBeenCalled(); + expect(getCircuitState('wrapped-2')?.metrics.failedCalls).toBe(1); + }); +}); diff --git a/backend/src/middleware/__tests__/cors.integration.test.ts b/backend/src/middleware/__tests__/cors.integration.test.ts new file mode 100644 index 00000000..2e603305 --- /dev/null +++ b/backend/src/middleware/__tests__/cors.integration.test.ts @@ -0,0 +1,336 @@ +/** + * cors.integration.test.ts — End-to-end CORS tests over a real HTTP server. + * + * Proves the dynamic origin whitelist drives real preflights and simple + * requests across the wire, including live policy mutations that take effect + * on the next request without a redeploy. + */ + +import express, { type Express } from 'express'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { AddressInfo } from 'node:net'; +import { createCorsMiddleware } from '../cors.js'; +import { corsRouter } from '../../routes/cors.js'; +import { addAllowedOrigin, getCorsMetrics, initCorsPolicy, resetCorsMetrics } from '../../services/cors.js'; + +let server: import('node:http').Server; +let base = ''; + +const APP_ORIGIN = 'https://app.example.com'; +const TENANT_ORIGIN = 'https://team.tenant.example.com'; +const DENIED_ORIGIN = 'https://evil.example.org'; + +async function request( + path: string, + options: { + method?: string; + origin?: string; + headers?: Record; + } = {}, +): Promise<{ status: number; body: unknown; headers: Headers }> { + const headers: Record = {}; + if (options.origin) headers.origin = options.origin; + Object.assign(headers, options.headers); + + const res = await fetch(`${base}${path}`, { + method: options.method ?? 'GET', + headers, + }); + const text = await res.text(); + let body: unknown = undefined; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { status: res.status, body, headers: res.headers }; +} + +describe('CORS over HTTP', () => { + beforeAll(async () => { + initCorsPolicy({ + allowedOrigins: [APP_ORIGIN, 'https://*.tenant.example.com'], + allowCredentials: true, + }); + + const app: Express = express(); + app.use( + createCorsMiddleware({ + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + maxAge: 86400, + }), + ); + + let hits = 0; + app.get('/data', (_req, res) => { + hits += 1; + res.json({ ok: true, hits, origin: _req.headers.origin ?? null }); + }); + + app.post('/data', (_req, res) => { + res.status(201).json({ created: true }); + }); + + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + beforeEach(() => { + resetCorsMetrics(); + }); + + it('reflects an allowed origin on a simple GET', async () => { + const res = await request('/data', { origin: APP_ORIGIN }); + + expect(res.status).toBe(200); + expect(res.headers.get('access-control-allow-origin')).toBe(APP_ORIGIN); + expect(res.headers.get('access-control-allow-credentials')).toBe('true'); + expect(res.headers.get('vary')).toContain('Origin'); + }); + + it('matches a wildcard tenant pattern from the browser origin', async () => { + const res = await request('/data', { origin: TENANT_ORIGIN }); + expect(res.headers.get('access-control-allow-origin')).toBe(TENANT_ORIGIN); + }); + + it('omits CORS header for a denied origin (browser blocks)', async () => { + const res = await request('/data', { origin: DENIED_ORIGIN }); + + expect(res.status).toBe(200); + expect((res.body as { hits: number }).hits).toBeGreaterThanOrEqual(1); + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + expect(res.headers.get('vary')).toContain('Origin'); + }); + + it('answers an allowed preflight with negotiated headers', async () => { + const res = await request('/data', { + method: 'OPTIONS', + origin: APP_ORIGIN, + headers: { + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'content-type, authorization', + }, + }); + + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe(APP_ORIGIN); + expect(res.headers.get('access-control-allow-credentials')).toBe('true'); + expect(res.headers.get('access-control-allow-methods')).toBe('POST'); + expect(res.headers.get('access-control-allow-headers')).toBe('content-type, authorization'); + expect(res.headers.get('access-control-max-age')).toBe('86400'); + }); + + it('answers a denied preflight with 204 but no allow headers', async () => { + const res = await request('/data', { + method: 'OPTIONS', + origin: DENIED_ORIGIN, + headers: { 'access-control-request-method': 'POST' }, + }); + + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + expect(res.headers.get('access-control-allow-methods')).toBeNull(); + expect(getCorsMetrics().preflightDenied).toBe(1); + }); + + it('serves a credentialed preflight for a POST with Authorization', async () => { + const res = await request('/data', { + method: 'OPTIONS', + origin: APP_ORIGIN, + headers: { 'access-control-request-method': 'POST' }, + }); + + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-methods')).toBe('POST'); + + const actual = await request('/data', { + method: 'POST', + origin: APP_ORIGIN, + headers: { authorization: 'Bearer xyz', 'content-type': 'application/json' }, + }); + expect(actual.status).toBe(201); + expect(actual.headers.get('access-control-allow-origin')).toBe(APP_ORIGIN); + }); + + it('applies origins added at runtime with no redeploy', async () => { + const before = await request('/data', { origin: 'https://fresh.example.com' }); + expect(before.headers.get('access-control-allow-origin')).toBeNull(); + + addAllowedOrigin('https://fresh.example.com'); + + const after = await request('/data', { origin: 'https://fresh.example.com' }); + expect(after.headers.get('access-control-allow-origin')).toBe('https://fresh.example.com'); + }); + + it('passes requests without an Origin through untouched', async () => { + const res = await request('/data'); + expect(res.status).toBe(200); + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + expect(res.headers.get('vary')).toContain('Origin'); + }); +}); + +// ─── Management router ────────────────────────────────────────────────────── + +let adminBase = ''; +let adminServer: import('node:http').Server; + +describe('CORS management router over HTTP', () => { + beforeAll(async () => { + initCorsPolicy({ + allowedOrigins: ['https://app.example.com'], + allowCredentials: true, + loader: async () => ['https://loaded.example.com', 'https://*.loaded.example.com'], + }); + + const app: Express = express(); + app.use(express.json()); + app.use('/api/v1/cors', corsRouter); + + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + adminBase = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/cors`; + adminServer = server; + }); + + afterAll(async () => { + await new Promise((resolve) => adminServer.close(resolve)); + }); + + it('GET /config reports the current policy and metrics', async () => { + const res = await fetch(`${adminBase}/config`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + allowCredentials: boolean; + wildcard: boolean; + origins: string[]; + version: number; + metrics: { allowedRequests: number }; + }; + expect(body.allowCredentials).toBe(true); + expect(body.wildcard).toBe(false); + expect(body.origins).toEqual(['https://app.example.com']); + expect(body.version).toBeGreaterThanOrEqual(0); + expect(body.metrics).toHaveProperty('allowedRequests'); + }); + + it('GET /origins lists the allowlist', async () => { + const res = await fetch(`${adminBase}/origins`); + expect(res.status).toBe(200); + const body = (await res.json()) as { origins: string[] }; + expect(body.origins).toEqual(['https://app.example.com']); + }); + + it('POST /origins adds a single origin and DELETE /origins removes it', async () => { + const add = await fetch(`${adminBase}/origins`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ origin: 'https://temp.example.com' }), + }); + expect(add.status).toBe(201); + const added = (await add.json()) as { size: number }; + expect(added.size).toBe(2); + + const remove = await fetch(`${adminBase}/origins?origin=https://temp.example.com`, { + method: 'DELETE', + }); + expect(remove.status).toBe(200); + const removed = (await remove.json()) as { removed: boolean }; + expect(removed.removed).toBe(true); + + const removeAgain = await fetch(`${adminBase}/origins?origin=https://temp.example.com`, { + method: 'DELETE', + }); + expect(((await removeAgain.json()) as { removed: boolean }).removed).toBe(false); + }); + + it('rejects invalid origins with 400 and keeps the allowlist intact', async () => { + const bad = await fetch(`${adminBase}/origins`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ origin: 'has space' }), + }); + expect(bad.status).toBe(400); + expect(((await bad.json()) as { error: { code: string } }).error.code).toBe('INVALID_CORS_ORIGIN'); + + const missing = await fetch(`${adminBase}/origins`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(missing.status).toBe(400); + + const config = await fetch(`${adminBase}/config`); + const body = (await config.json()) as { origins: string[] }; + expect(body.origins).toEqual(['https://app.example.com']); + }); + + it('PUT /config replaces the allowlist and toggles credentials', async () => { + const res = await fetch(`${adminBase}/config`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + allowedOrigins: ['https://next.example.com'], + allowCredentials: false, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { origins: string[]; allowCredentials: boolean }; + expect(body.origins).toEqual(['https://next.example.com']); + expect(body.allowCredentials).toBe(false); + + // Restore for the other tests. + await fetch(`${adminBase}/config`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + allowedOrigins: ['https://app.example.com'], + allowCredentials: true, + }), + }); + }); + + it('PUT /config with a bad entry returns 400 and does not mutate', async () => { + const res = await fetch(`${adminBase}/config`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ allowedOrigins: ['https://ok.example.com', 'not valid!'] }), + }); + expect(res.status).toBe(400); + + const config = await fetch(`${adminBase}/config`); + expect(((await config.json()) as { origins: string[] }).origins).toEqual(['https://app.example.com']); + }); + + it('PUT /config with a non-array returns a validation error', async () => { + const res = await fetch(`${adminBase}/config`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ allowedOrigins: 'https://app.example.com' }), + }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe('VALIDATION_FAILED'); + }); + + it('POST /refresh pulls the allowlist through the loader', async () => { + const res = await fetch(`${adminBase}/refresh`, { method: 'POST' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { message: string; origins: string[] }; + expect(body.origins).toEqual(['https://*.loaded.example.com', 'https://loaded.example.com']); + }); + + it('DELETE /origins without an origin param returns 400', async () => { + const res = await fetch(`${adminBase}/origins`, { method: 'DELETE' }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe('VALIDATION_FAILED'); + }); +}); \ No newline at end of file diff --git a/backend/src/middleware/__tests__/cors.test.ts b/backend/src/middleware/__tests__/cors.test.ts new file mode 100644 index 00000000..83d42123 --- /dev/null +++ b/backend/src/middleware/__tests__/cors.test.ts @@ -0,0 +1,355 @@ +/** + * cors.test.ts — Unit tests for the CORS middleware backed by the dynamic + * origin policy: simple requests, preflights, credential handling, open-mode + * reflection, denials, and live policy mutations. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NextFunction, Request, Response } from 'express'; +import { createCorsMiddleware, DEFAULT_ALLOWED_HEADERS, DEFAULT_METHODS } from '../cors.js'; +import { addAllowedOrigin, getCorsMetrics, initCorsPolicy, resetCorsMetrics } from '../../services/cors.js'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +interface ResProbe { + res: Response; + headers: Record; + receivedBody: unknown; + endCalled: boolean; + jsonCalled: boolean; + statusCode: number; +} + +function makeReq(overrides: Partial = {}): Request { + return { + method: 'GET', + headers: {}, + originalUrl: '/api/v1/test', + ...overrides, + } as unknown as Request; +} + +function makeRes(): ResProbe { + const headers: Record = {}; + const probe: ResProbe = { + headers, + receivedBody: undefined, + endCalled: false, + jsonCalled: false, + statusCode: 200, + res: {} as Response, + }; + probe.res = { + statusCode: 200, + setHeader: vi.fn((name: string, value: string | number) => { + headers[name] = value; + }), + getHeader: vi.fn((name: string) => headers[name]), + status: vi.fn(function (code: number) { + probe.statusCode = code; + return probe.res; + }), + end: vi.fn(() => { + probe.endCalled = true; + return probe.res; + }), + json: vi.fn(function (body: unknown) { + probe.jsonCalled = true; + probe.receivedBody = body; + return probe.res; + }), + } as unknown as Response; + return probe; +} + +function run( + mw: (req: Request, res: Response, next: NextFunction) => void, + req: Request, + res: Response, +): boolean { + let calledNext = false; + mw(req, res, () => { + calledNext = true; + }); + return calledNext; +} + +function header(probe: ResProbe, name: string): string | number | string[] | undefined { + return probe.headers[name]; +} + +const ACAO = 'Access-Control-Allow-Origin'; + +describe('cors middleware', () => { + beforeEach(() => { + resetCorsMetrics(); + initCorsPolicy({ + allowedOrigins: [ + 'https://app.example.com', + 'https://*.tenant.example.com', + ], + allowCredentials: true, + }); + }); + + describe('simple requests', () => { + it('passes through without CORS headers when no Origin is sent', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + const next = run(mw, makeReq({ headers: {} }), probe.res); + + expect(next).toBe(true); + expect(probe.endCalled).toBe(false); + expect(header(probe, ACAO)).toBeUndefined(); + expect(header(probe, 'Vary')).toBe('Origin'); + }); + + it('reflects an allowed origin', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + const next = run( + mw, + makeReq({ headers: { origin: 'https://app.example.com' } }), + probe.res, + ); + + expect(next).toBe(true); + expect(header(probe, ACAO)).toBe('https://app.example.com'); + expect(header(probe, 'Access-Control-Allow-Credentials')).toBe('true'); + }); + + it('does NOT set CORS headers for a denied origin but still passes through', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + const next = run( + mw, + makeReq({ headers: { origin: 'https://evil.example.org' } }), + probe.res, + ); + + expect(next).toBe(true); + expect(header(probe, ACAO)).toBeUndefined(); + expect(header(probe, 'Vary')).toBe('Origin'); + }); + + it('exposes extra headers when configured', () => { + const probe = makeRes(); + const mw = createCorsMiddleware({ exposedHeaders: ['X-Rate-Limit', 'X-Request-Id'] }); + run(mw, makeReq({ headers: { origin: 'https://app.example.com' } }), probe.res); + + expect(header(probe, 'Access-Control-Expose-Headers')).toBe('X-Rate-Limit, X-Request-Id'); + }); + + it('supports the wildcard tenant pattern', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + run( + mw, + makeReq({ headers: { origin: 'https://team.tenant.example.com' } }), + probe.res, + ); + expect(header(probe, ACAO)).toBe('https://team.tenant.example.com'); + }); + + it('does not set CORS headers for non-Origin-less POST mutations', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + const next = run( + mw, + makeReq({ method: 'POST', headers: { origin: 'https://evil.example.org' } }), + probe.res, + ); + expect(next).toBe(true); + expect(header(probe, ACAO)).toBeUndefined(); + }); + }); + + describe('open mode and wildcard', () => { + it('open mode without credentials emits a bare "*"', () => { + resetCorsMetrics(); + initCorsPolicy({ allowedOrigins: ['*'], allowCredentials: false }); + const probe = makeRes(); + const mw = createCorsMiddleware(); + run(mw, makeReq({ headers: { origin: 'https://anything.example.com' } }), probe.res); + + expect(header(probe, ACAO)).toBe('*'); + expect(header(probe, 'Access-Control-Allow-Credentials')).toBeUndefined(); + expect(header(probe, 'Access-Control-Expose-Headers')).toBeUndefined(); + }); + + it('open mode WITH credentials reflects the concrete origin', () => { + resetCorsMetrics(); + initCorsPolicy({ allowedOrigins: ['*'], allowCredentials: true }); + const probe = makeRes(); + const mw = createCorsMiddleware(); + run(mw, makeReq({ headers: { origin: 'https://app.example.com' } }), probe.res); + + expect(header(probe, ACAO)).toBe('https://app.example.com'); + expect(header(probe, 'Access-Control-Allow-Credentials')).toBe('true'); + }); + }); + + describe('dynamic whitelisting', () => { + it('honours origins added to the policy AFTER the middleware was created', () => { + const probe = makeRes(); + const mw = createCorsMiddleware({ credentials: false }); + run(mw, makeReq({ headers: { origin: 'https://new.example.com' } }), probe.res); + expect(header(probe, ACAO)).toBeUndefined(); + + addAllowedOrigin('https://new.example.com'); + + const probe2 = makeRes(); + run(mw, makeReq({ headers: { origin: 'https://new.example.com' } }), probe2.res); + expect(header(probe2, ACAO)).toBe('https://new.example.com'); + }); + + it('seeds the shared policy when allowedOrigins is passed to the factory', () => { + resetCorsMetrics(); + initCorsPolicy({ allowedOrigins: ['https://stale.example.com'] }); + createCorsMiddleware({ allowedOrigins: ['https://fresh.example.com'] }); + + const probe = makeRes(); + const mw = createCorsMiddleware(); + run(mw, makeReq({ headers: { origin: 'https://fresh.example.com' } }), probe.res); + expect(header(probe, ACAO)).toBe('https://fresh.example.com'); + }); + }); + + describe('preflight requests', () => { + const preflight = (origin = 'https://app.example.com') => + makeReq({ + method: 'OPTIONS', + headers: { + origin, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'content-type,authorization', + }, + }); + + it('answers an allowed preflight with 204 and negotiated headers', () => { + const probe = makeRes(); + const mw = createCorsMiddleware({ credentials: true, maxAge: 600 }); + const next = run(mw, preflight(), probe.res); + + expect(next).toBe(false); + expect(probe.endCalled).toBe(true); + expect(probe.statusCode).toBe(204); + expect(header(probe, ACAO)).toBe('https://app.example.com'); + expect(header(probe, 'Access-Control-Allow-Credentials')).toBe('true'); + expect(header(probe, 'Access-Control-Allow-Methods')).toBe('POST'); + expect(header(probe, 'Access-Control-Allow-Headers')).toBe('content-type, authorization'); + expect(header(probe, 'Access-Control-Max-Age')).toBe('600'); + expect(header(probe, 'Vary')).toContain('Origin'); + expect(header(probe, 'Vary')).toContain('Access-Control-Request-Method'); + expect(header(probe, 'Vary')).toContain('Access-Control-Request-Headers'); + }); + + it('answers a denied preflight with 204 but no allow headers', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + const next = run(mw, preflight('https://evil.example.org'), probe.res); + + expect(next).toBe(false); + expect(probe.endCalled).toBe(true); + expect(header(probe, ACAO)).toBeUndefined(); + expect(header(probe, 'Access-Control-Allow-Methods')).toBeUndefined(); + expect(getCorsMetrics().preflightDenied).toBe(1); + }); + + it('omits Allow-Methods when the requested method is not configured', () => { + const probe = makeRes(); + const mw = createCorsMiddleware({ methods: ['GET', 'OPTIONS'] }); + run(mw, preflight(), probe.res); + + expect(header(probe, 'Access-Control-Allow-Methods')).toBeUndefined(); + expect(header(probe, ACAO)).toBeTruthy(); + }); + + it('filters requested headers against the configured allow list', () => { + const probe = makeRes(); + const mw = createCorsMiddleware({ allowedHeaders: ['Authorization'] }); + run(mw, preflight(), probe.res); + + expect(header(probe, 'Access-Control-Allow-Headers')).toBe('authorization'); + }); + + it('emits Access-Control-Allow-Private-Network on PNA preflights', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + const req = preflight(); + req.headers['access-control-request-private-network'] = 'true'; + run(mw, req, probe.res); + + expect(header(probe, 'Access-Control-Allow-Private-Network')).toBe('true'); + }); + + it('hands the preflight to the app when preflightContinue is set', () => { + const probe = makeRes(); + const mw = createCorsMiddleware({ preflightContinue: true }); + const next = run(mw, preflight(), probe.res); + + expect(next).toBe(true); + expect(probe.endCalled).toBe(false); + expect(header(probe, ACAO)).toBe('https://app.example.com'); + }); + + it('treats a plain OPTIONS (no request-method) as a simple request', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + const next = run( + mw, + makeReq({ method: 'OPTIONS', headers: { origin: 'https://app.example.com' } }), + probe.res, + ); + + expect(next).toBe(true); + expect(probe.endCalled).toBe(false); + expect(header(probe, ACAO)).toBe('https://app.example.com'); + expect(header(probe, 'Access-Control-Allow-Methods')).toBeUndefined(); + }); + }); + + describe('metrics and edge input', () => { + it('counts allowed and denied simple requests', () => { + const mw = createCorsMiddleware(); + run(mw, makeReq({ headers: { origin: 'https://app.example.com' } }), makeRes().res); + run(mw, makeReq({ headers: { origin: 'https://app.example.com' } }), makeRes().res); + run(mw, makeReq({ headers: { origin: 'https://evil.example.org' } }), makeRes().res); + + const m = getCorsMetrics(); + expect(m.allowedRequests).toBe(2); + expect(m.deniedRequests).toBe(1); + expect(m.preflights).toBe(0); + }); + + it('uses the first origin from a space-separated Origin list', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + const multiple = makeReq({ + headers: { origin: 'https://app.example.com https://sandbox.example.com' }, + }); + const next = run(mw, multiple, probe.res); + + expect(next).toBe(true); + expect(header(probe, ACAO)).toBe('https://app.example.com'); + }); + + it('denies the null origin without CORS headers', () => { + const probe = makeRes(); + const mw = createCorsMiddleware(); + const next = run(mw, makeReq({ headers: { origin: 'null' } }), probe.res); + + expect(next).toBe(true); + expect(header(probe, ACAO)).toBeUndefined(); + expect(getCorsMetrics().deniedRequests).toBe(1); + }); + }); + + describe('exported defaults', () => { + it('exposes sane default method/header lists', () => { + expect(DEFAULT_METHODS).toContain('GET'); + expect(DEFAULT_METHODS).toContain('OPTIONS'); + expect(DEFAULT_ALLOWED_HEADERS).toEqual(['Content-Type', 'Authorization']); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/middleware/__tests__/etag-cache.integration.test.ts b/backend/src/middleware/__tests__/etag-cache.integration.test.ts new file mode 100644 index 00000000..a61bcae9 --- /dev/null +++ b/backend/src/middleware/__tests__/etag-cache.integration.test.ts @@ -0,0 +1,213 @@ +/** + * etag-cache.integration.test.ts — Issue #622 + * + * End-to-end tests over a real HTTP server proving that the ETag middleware + * and the cacheControl middleware work together correctly across the wire: + * conditional requests (304), X-Cache behaviour, stale-while-revalidate and + * the no-store error guard. Also guards the historical bug where concurrent + * in-memory cache misses never resolved the handler. + */ + +import express, { type Express } from 'express'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { AddressInfo } from 'node:net'; +import { etag } from '../etag.js'; +import { cacheControl, clearMemoryCache } from '../cache.js'; + +let server: import('node:http').Server; +let base = ''; + +async function call( + path: string, + options: { + method?: 'GET' | 'HEAD' | 'POST'; + headers?: Record; + } = {}, +): Promise<{ status: number; body: unknown; headers: Headers }> { + const res = await fetch(`${base}${path}`, { + method: options.method ?? 'GET', + headers: options.headers, + }); + const text = await res.text(); + let body: unknown = undefined; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { status: res.status, body, headers: res.headers }; +} + +describe('etag + cacheControl over HTTP', () => { + beforeAll(async () => { + const app: Express = express(); + app.use(express.json()); + app.disable('etag'); // exercise OUR middleware, not Express's auto-ETag + + let counter = 0; + + // ETag-only route (stable body so a conditional request can match) + app.get('/etag', etag(), (_req, res) => { + res.json({ ok: true }); + }); + + // Header-only cache route + app.get('/header-only', cacheControl({ maxAge: 60 }), (_req, res) => { + res.json({ source: 'header-only', n: ++counter }); + }); + + // In-memory cache route (served from cache after the first request) + app.get('/memory', cacheControl({ maxAge: 300, inMemory: true }), (_req, res) => { + res.json({ source: 'memory', n: ++counter }); + }); + + // In-memory route with stale-while-revalidate (1s freshness) + app.get( + '/memory-swr', + cacheControl({ maxAge: 1, inMemory: true, staleWhileRevalidate: 300 }), + (_req, res) => { + res.json({ source: 'memory-swr', n: ++counter }); + }, + ); + + // Error route: must never be cached or tagged + app.get( + '/error', + cacheControl({ maxAge: 60, inMemory: true }), + (_req, res) => { + res.status(503).json({ error: 'down', n: ++counter }); + }, + ); + + // Same path, mutation: the cache middleware must pass it straight through + app.post('/memory', (_req, res) => { + res.json({ source: 'memory', method: 'POST', n: ++counter }); + }); + + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + beforeEach(() => { + clearMemoryCache(); + }); + + it('etag route serves 200 with an ETag then 304 on If-None-Match', async () => { + const first = await call('/etag'); + expect(first.status).toBe(200); + const etagHeader = first.headers.get('etag'); + expect(etagHeader).toBeTruthy(); + + const second = await call('/etag', { headers: { 'if-none-match': etagHeader! } }); + expect(second.status).toBe(304); + expect(second.body).toBeUndefined(); + }); + + it('header-only mode never stores, so the body always changes', async () => { + const a = await call('/header-only'); + expect(a.status).toBe(200); + expect((a.body as { source: string }).source).toBe('header-only'); + expect(a.headers.get('cache-control')).toBe('public, max-age=60'); + expect(a.headers.get('etag')).toBeTruthy(); + + const b = await call('/header-only'); + expect((b.body as { n: number }).n).toBeGreaterThan((a.body as { n: number }).n); + }); + + it('in-memory mode caches: first MISS, then HITs with a stable body', async () => { + const first = await call('/memory'); + expect(first.status).toBe(200); + expect(first.headers.get('x-cache')).toBe('MISS'); + + const second = await call('/memory'); + expect(second.status).toBe(200); + expect(second.headers.get('x-cache')).toBe('HIT'); + expect(second.body).toEqual(first.body); + + const third = await call('/memory'); + expect(third.headers.get('x-cache')).toBe('HIT'); + expect(third.body).toEqual(first.body); + }); + + it('serves 304 from the in-memory cache when If-None-Match matches', async () => { + const first = await call('/memory'); + const etagHeader = first.headers.get('etag') as string; + + const conditional = await call('/memory', { + headers: { 'if-none-match': etagHeader }, + }); + expect(conditional.status).toBe(304); + expect(conditional.body).toBeUndefined(); + + const stale = await call('/memory', { headers: { 'if-none-match': '"deadbeef"' } }); + expect(stale.status).toBe(200); + expect(stale.headers.get('x-cache')).toBe('HIT'); + }); + + it('concurrent in-memory misses all resolve (regression: handler hang)', async () => { + const responses = await Promise.all( + Array.from({ length: 8 }, () => call('/memory')), + ); + + for (const r of responses) { + expect(r.status).toBe(200); + expect((r.body as { source: string }).source).toBe('memory'); + expect(['MISS', 'HIT']).toContain(r.headers.get('x-cache')); + } + // Every response is equally valid: whatever was cached converges on equal bodies. + const ns = responses.map((r) => (r.body as { n: number }).n); + expect(new Set(ns).size).toBeLessThanOrEqual(2); + + // And the winner is now cached for everyone else. + const last = await call('/memory'); + expect(last.headers.get('x-cache')).toBe('HIT'); + }); + + it('serves stale content with X-Cache STALE after TTL expiry when SWR is on', async () => { + const first = await call('/memory-swr'); + expect(first.status).toBe(200); + expect(first.headers.get('x-cache')).toBe('MISS'); + + const ttlAfterMs = 1100; + await new Promise((resolve) => setTimeout(resolve, ttlAfterMs)); + + const stale = await call('/memory-swr'); + expect(stale.status).toBe(200); + expect(stale.headers.get('x-cache')).toBe('STALE'); + expect(stale.body).toEqual(first.body); + }); + + it('error responses are never cached or tagged', async () => { + const first = await call('/error'); + expect(first.status).toBe(503); + expect(first.headers.get('cache-control')).toBe('no-store'); + expect(first.headers.get('x-cache')).toBeNull(); + expect(first.headers.get('etag')).toBeNull(); + + const second = await call('/error'); + expect(second.status).toBe(503); + // A fresh handler runs (counter advances) because nothing was cached. + expect((second.body as { n: number }).n).toBeGreaterThan((first.body as { n: number }).n); + }); + + it('POST mutations pass straight through without cache/etag headers', async () => { + const res = await fetch(`${base}/memory`, { method: 'POST' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { method: string; n: number }; + expect(body.method).toBe('POST'); + expect(res.headers.get('x-cache')).toBeNull(); + expect(res.headers.get('etag')).toBeNull(); + expect(res.headers.get('cache-control')).toBeNull(); + // Two POSTs both reach the handler (nothing is stored for mutations). + const second = await fetch(`${base}/memory`, { method: 'POST' }); + const body2 = (await second.json()) as { n: number }; + expect(body2.n).toBeGreaterThan(body.n); + }); +}); \ No newline at end of file diff --git a/backend/src/middleware/__tests__/etag.test.ts b/backend/src/middleware/__tests__/etag.test.ts index 938bb597..71152141 100644 --- a/backend/src/middleware/__tests__/etag.test.ts +++ b/backend/src/middleware/__tests__/etag.test.ts @@ -20,6 +20,8 @@ import { strongMatch, getETagMetrics, resetETagMetrics, + defaultETag, + publicETag, } from '../etag.js'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -48,10 +50,15 @@ function makeRes(): { let endCalled = false; const res = { + statusCode: 200, setHeader: vi.fn((name: string, value: string | number) => { headers[name] = value; }), + getHeader: vi.fn((name: string): string | number | undefined => { + return headers[name]; + }), status: vi.fn(function (code: number) { + res.statusCode = code; sentStatus = code; return res; }), @@ -189,7 +196,7 @@ describe('etag() middleware', () => { it('returns full response when If-None-Match does not match', () => { const req = makeReq({ headers: { 'if-none-match': '"stale-etag"' } }); const result = makeRes(); - const { res, jsonCalled } = result; + const { res } = result; const mw = etag(); mw(req, res, next); @@ -278,4 +285,152 @@ describe('etag() middleware', () => { } expect(tags.size).toBe(100); }); + + it('does not tag or short-circuit error responses (status >= 400)', () => { + const req = makeReq(); + const wrapper = makeRes(); + const mw = etag(); + (wrapper.res.status as unknown as ReturnType)(500); + + mw(req, wrapper.res, next); + (wrapper.res.json as ReturnType)({ error: 'boom' }); + + expect(wrapper.headers['ETag']).toBeUndefined(); + expect(wrapper.jsonCalled).toBe(true); + }); + + it('honours an ETag already set by another middleware', () => { + const req = makeReq(); + const { res, headers } = makeRes(); + const mw = etag(); + res.setHeader('ETag', '"precomputed"'); + + mw(req, res, next); + (res.json as ReturnType)({ data: 1 }); + + expect(headers['ETag']).toBe('"precomputed"'); + + // Metrics: no new tag generated, nothing incrementing generated counter + const m = getETagMetrics(); + expect(m.generated).toBe(0); + }); + + it('bypasses requests carrying an API key when configured', () => { + const req = makeReq({ headers: { 'x-api-key': 'k123' } }); + const { res, headers } = makeRes(); + const mw = etag({ bypassAuthenticated: true }); + + mw(req, res, next); + + expect(headers['ETag']).toBeUndefined(); + expect(next).toHaveBeenCalledOnce(); + }); + + it('does not bypass authenticated requests when bypass is disabled', () => { + const req = makeReq({ headers: { authorization: 'Bearer x' } }); + const { res, headers } = makeRes(); + const mw = etag(); + + mw(req, res, next); + (res.json as ReturnType)({ private: true }); + + expect(headers['ETag']).toBeDefined(); + }); + + it('skips ETag generation for oversized responses', () => { + const req = makeReq(); + const wrapper = makeRes(); + const mw = etag({ maxBodySize: 10 }); + + mw(req, wrapper.res, next); + (wrapper.res.json as ReturnType)({ data: 'a-payload-larger-than-the-limit' }); + + expect(wrapper.headers['ETag']).toBeUndefined(); + expect(wrapper.jsonCalled).toBe(true); + + const m = getETagMetrics(); + expect(m.bypassed).toBeGreaterThan(0); + }); + + it('matches a client ETag from a comma-separated If-None-Match list', () => { + const body = { data: 'listed' }; + const tag = generateETag(JSON.stringify(body)); + const req = makeReq({ headers: { 'if-none-match': `"stale", ${tag}` } }); + const wrapper = makeRes(); + const mw = etag(); + + mw(req, wrapper.res, next); + (wrapper.res.json as ReturnType)(body); + + expect(wrapper.sentStatus).toBe(304); + }); + + it('responds 200 when no entry in a If-None-Match list matches', () => { + const req = makeReq({ headers: { 'if-none-match': '"one", "two"' } }); + const result = makeRes(); + const { res } = result; + const mw = etag(); + + mw(req, res, next); + (res.json as ReturnType)({ data: 'fresh' }); + + expect(result.sentStatus).toBeNull(); + expect(result.jsonCalled).toBe(true); + expect(getETagMetrics().mismatched).toBe(1); + }); + + it('matches when the client sends a weak (W/) variant of a strong ETag', () => { + const body = { data: 'weak-client' }; + const strong = generateETag(JSON.stringify(body)); + const weakVariant = strong.replace(/^"/, 'W/"'); + const req = makeReq({ headers: { 'if-none-match': weakVariant } }); + const wrapper = makeRes(); + const mw = etag(); + + mw(req, wrapper.res, next); + (wrapper.res.json as ReturnType)(body); + + expect(wrapper.sentStatus).toBe(304); + }); + + it('accepts an arbitrary hash algorithm', () => { + const tag = generateETag('hello', 'md5'); + expect(tag).toMatch(/^"[a-f0-9]+"$/); + expect(tag).not.toBe(generateETag('hello', 'sha256')); + }); + + it('handles HEAD requests with conditional 304', () => { + const body = { data: 'head-304' }; + const tag = generateETag(JSON.stringify(body)); + const req = makeReq({ method: 'HEAD', headers: { 'if-none-match': tag } }); + const wrapper = makeRes(); + const mw = etag(); + + mw(req, wrapper.res, next); + (wrapper.res.json as ReturnType)(body); + + expect(wrapper.sentStatus).toBe(304); + }); + + it('defaultETag and publicETag presets wire the middleware', () => { + expect(typeof defaultETag()).toBe('function'); + expect(typeof publicETag()).toBe('function'); + + // publicETag bypasses authenticated traffic + const req = makeReq({ headers: { authorization: 'Bearer t' } }); + const { res, headers } = makeRes(); + publicETag()(req, res, next); + expect(headers['ETag']).toBeUndefined(); + }); + + it('strongMatch rejects weak ETags even when underlying value matches', () => { + expect(strongMatch('"abc"', 'W/"abc"')).toBe(false); + expect(strongMatch('"abc"', '"abc"')).toBe(true); + }); + + it('weakMatch strips W/ prefixes on both sides', () => { + expect(weakMatch('W/"x"', 'W/"x"')).toBe(true); + expect(weakMatch('W/"x"', '"x"')).toBe(true); + expect(weakMatch('"x"', '"y"')).toBe(false); + }); }); diff --git a/backend/src/middleware/__tests__/webhookVerification.dispatcher.test.ts b/backend/src/middleware/__tests__/webhookVerification.dispatcher.test.ts new file mode 100644 index 00000000..38a61337 --- /dev/null +++ b/backend/src/middleware/__tests__/webhookVerification.dispatcher.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Request, Response, NextFunction } from 'express'; +import { verifyWebhookProvider } from '../webhookVerification.js'; +import { AppError } from '../errorHandler.js'; +import { clearReplayCache } from '../../services/webhooks/replay.js'; + +const h = vi.hoisted(() => ({ + customResult: {} as Record, + stripeResult: {} as Record, + githubResult: {} as Record, + paypalResult: {} as Record, + retryResult: undefined as unknown, + queueCalls: [] as unknown[], +})); + +vi.mock('../../services/webhooks/verification.js', () => ({ + queueFailedWebhook: (...args: unknown[]) => { + h.queueCalls.push(args); + return { id: 'whe_test_1', provider: 'custom' }; + }, + retryWebhook: () => h.retryResult, +})); + +vi.mock('../../services/stripe.js', () => ({ + constructWebhookEvent: () => { + throw new Error('stripe not exercised in this suite'); + }, +})); + +vi.mock('../../services/webhooks/providers.js', () => ({ + verifyStripeProviderWebhook: () => h.stripeResult, + verifyGithubProviderWebhook: () => h.githubResult, + verifyPaypalProviderWebhook: () => h.paypalResult, + verifyCustomProviderWebhook: () => h.customResult, +})); + +const res = {} as Response; + +function validResult(overrides: Record = {}) { + return { + isValid: true, + provider: 'custom', + eventId: 'evt_dispatch_1', + timestamp: new Date(), + body: '{"a":1}', + payload: { parsed: true }, + ...overrides, + }; +} + +function makeRequest(overrides: Partial = {}): Request { + return { + headers: {}, + body: {}, + rawBody: '{"a":1}', + ...overrides, + } as unknown as Request; +} + +function makeNext() { + const calls: Array = []; + const next: NextFunction = ((err?: unknown) => { + calls.push(err as Error | undefined); + }) as NextFunction; + return { next, calls }; +} + +describe('verifyWebhookProvider dispatcher', () => { + beforeEach(() => { + clearReplayCache(); + h.queueCalls = []; + h.retryResult = undefined; + h.customResult = validResult(); + h.stripeResult = validResult(); + h.githubResult = validResult(); + h.paypalResult = validResult(); + }); + + afterEach(() => { + clearReplayCache(); + }); + + it('passes valid results through and replaces the body with the parsed payload', async () => { + const middleware = verifyWebhookProvider('custom'); + const req = makeRequest(); + const { next, calls } = makeNext(); + + await middleware(req, res, next); + + expect(calls).toHaveLength(1); + expect(calls[0]).toBeUndefined(); + expect(req.webhookVerification?.isValid).toBe(true); + expect(req.body).toEqual({ parsed: true }); + }); + + it('keeps the request body when the result has no payload', async () => { + h.customResult = validResult({ payload: undefined }); + const middleware = verifyWebhookProvider('custom'); + const req = makeRequest({ body: { original: true } }); + const { next, calls } = makeNext(); + + await middleware(req, res, next); + + expect(calls[0]).toBeUndefined(); + expect(req.body).toEqual({ original: true }); + expect(h.queueCalls).toHaveLength(0); + }); + + it('rejects invalid results with 401 WEBHOOK_VERIFICATION_FAILED', async () => { + h.customResult = validResult({ isValid: false, error: 'Signature verification failed' }); + const middleware = verifyWebhookProvider('custom'); + const { next, calls } = makeNext(); + + await middleware(makeRequest(), res, next); + + expect(h.queueCalls).toHaveLength(1); + expect(calls[0]).toBeInstanceOf(AppError); + expect((calls[0] as AppError).statusCode).toBe(401); + }); + + it('throws 409 WEBHOOK_REPLAY on duplicate event deliveries', async () => { + const middleware = verifyWebhookProvider('custom'); + const { next, calls } = makeNext(); + h.customResult = validResult({ eventId: 'evt_double' }); + + await middleware(makeRequest({ headers: { 'x-webhook-id': 'evt_double' } }), res, next); + expect(calls[0]).toBeUndefined(); + + calls.length = 0; + await middleware(makeRequest({ headers: { 'x-webhook-id': 'evt_double' } }), res, next); + expect(calls[0]).toBeInstanceOf(AppError); + expect((calls[0] as AppError).statusCode).toBe(409); + expect((calls[0] as AppError).code).toBe('WEBHOOK_REPLAY'); + }); + + it('revalidates and passes on timeout/network failures when a retry succeeds', async () => { + h.customResult = validResult({ isValid: false, error: 'Verification failed: network timeout' }); + h.retryResult = { isValid: true }; + const middleware = verifyWebhookProvider('custom'); + const req = makeRequest(); + const { next, calls } = makeNext(); + + await middleware(req, res, next); + + expect(calls).toHaveLength(1); + expect(calls[0]).toBeUndefined(); + expect(req.webhookVerification?.isValid).toBe(true); + expect(req.body).toEqual({ parsed: true }); + }); + + it('rejects with 401 when the retry also fails', async () => { + h.customResult = validResult({ isValid: false, error: 'timeout after retries' }); + h.retryResult = { isValid: false }; + const middleware = verifyWebhookProvider('custom'); + const { next, calls } = makeNext(); + + await middleware(makeRequest(), res, next); + + expect(calls[0]).toBeInstanceOf(AppError); + expect((calls[0] as AppError).statusCode).toBe(401); + }); + + it('wires every provider through the dispatcher', async () => { + for (const middleware of [verifyWebhookProvider('stripe'), verifyWebhookProvider('github'), verifyWebhookProvider('paypal')]) { + const { next, calls } = makeNext(); + await middleware(makeRequest(), res, next); + expect(calls[0]).toBeUndefined(); + } + }); +}); \ No newline at end of file diff --git a/backend/src/middleware/__tests__/webhookVerification.integration.test.ts b/backend/src/middleware/__tests__/webhookVerification.integration.test.ts new file mode 100644 index 00000000..88dd1b1b --- /dev/null +++ b/backend/src/middleware/__tests__/webhookVerification.integration.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import type { Server } from 'node:http'; +import express from 'express'; +import { webhookHandlersRouter } from '../../routes/webhookHandlers.js'; +import { errorHandler } from '../errorHandler.js'; +import { + initWebhookKeyRegistry, + resetWebhookKeyRegistry, +} from '../../services/webhookKeys.js'; +import { clearReplayCache } from '../../services/webhooks/replay.js'; +import { generateWebhookSignature } from '../../services/webhooks/verification.js'; + +vi.mock('../../services/stripe.js', () => ({ + constructWebhookEvent: () => { + throw new Error('stripe not exercised in this suite'); + }, +})); + +const PAYLOAD = JSON.stringify({ event: 'payment.succeeded', data: { id: 'evt_int_1' } }); +const LEGACY_CUSTOM_SECRET = 'whsec_test_default_custom_secret_key_32_chars_min'; + +let server: Server; +let base: string; + +async function setupServer() { + const app = express(); + app.use(webhookHandlersRouter); + app.use(errorHandler); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + if (address && typeof address === 'object') { + base = `http://127.0.0.1:${address.port}`; + } else { + throw new Error('Failed to bind test server'); + } +} + +function postCustom(path: string, init: { headers?: Record; body?: string }) { + const headers: Record = { 'Content-Type': 'application/json', ...(init.headers ?? {}) }; + return fetch(`${base}${path}`, { + method: 'POST', + headers, + ...(init.body !== undefined ? { body: init.body } : {}), + }); +} + +describe('webhook verification integration (key rotation)', () => { + let clock = 1_700_000_000_000; + + beforeAll(async () => { + await setupServer(); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + beforeEach(() => { + clock = 1_700_000_000_000; + resetWebhookKeyRegistry(); + clearReplayCache(); + }); + + afterEach(() => { + resetWebhookKeyRegistry(); + clearReplayCache(); + }); + + it('accepts a valid custom webhook signed with the active key', async () => { + const registry = initWebhookKeyRegistry({ keys: [{ provider: 'custom', secret: 'rotation_secret_01_abcdefghijklmnop' }] }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + + const res = await postCustom('/custom', { + headers: { + 'X-AgenticPay-Signature': signed.signature, + 'X-AgenticPay-Timestamp': signed.timestamp, + }, + body: PAYLOAD, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ received: true }); + }); + + it('rejects a tampered payload with 401 WEBHOOK_VERIFICATION_FAILED', async () => { + const registry = initWebhookKeyRegistry({ keys: [{ provider: 'custom', secret: 'rotation_secret_02_abcdefghijklmnop' }] }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + const tampered = JSON.stringify({ event: 'payment.succeeded', data: { id: 'evt_int_1', amount: 999999 } }); + + const res = await postCustom('/custom', { + headers: { + 'X-AgenticPay-Signature': signed.signature, + 'X-AgenticPay-Timestamp': signed.timestamp, + }, + body: tampered, + }); + + expect(res.status).toBe(401); + expect((await res.json())?.error?.message).toMatch(/verification failed/i); + }); + + it('rejects requests missing a signature', async () => { + initWebhookKeyRegistry({ keys: [{ provider: 'custom', secret: 'rotation_secret_03_abcdefghijklmnop' }] }); + + const res = await postCustom('/custom', { body: PAYLOAD }); + + expect(res.status).toBe(401); + }); + + it('keeps verifying with the old key during the rotation overlap', async () => { + const registry = initWebhookKeyRegistry({ + now: () => clock, + overlapSeconds: 3600, + keys: [{ provider: 'custom', secret: 'rotation_secret_04_abcdefghijklmnop' }], + }); + const signedOld = registry.sign({ provider: 'custom', body: PAYLOAD }); + registry.rotate({ provider: 'custom', secret: 'rotation_secret_05_abcdefghijklmnop' }); + clock += 60_000; + + const res = await postCustom('/custom', { + headers: { + 'X-AgenticPay-Signature': signedOld.signature, + 'X-AgenticPay-Timestamp': signedOld.timestamp, + }, + body: PAYLOAD, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ received: true }); + }); + + it('rejects the old key once the rotation overlap elapses', async () => { + const registry = initWebhookKeyRegistry({ + now: () => clock, + overlapSeconds: 3600, + keys: [{ provider: 'custom', secret: 'rotation_secret_06_abcdefghijklmnop' }], + }); + const signedOld = registry.sign({ provider: 'custom', body: PAYLOAD }); + registry.rotate({ provider: 'custom', secret: 'rotation_secret_07_abcdefghijklmnop' }); + clock += 3600 * 1000 + 1000; + + const res = await postCustom('/custom', { + headers: { + 'X-AgenticPay-Signature': signedOld.signature, + 'X-AgenticPay-Timestamp': signedOld.timestamp, + }, + body: PAYLOAD, + }); + + expect(res.status).toBe(401); + }); + + it('falls back to legacy verification when no registry keys exist', async () => { + initWebhookKeyRegistry(); + const timestamp = new Date().toISOString(); + const signature = generateWebhookSignature(PAYLOAD, LEGACY_CUSTOM_SECRET, timestamp); + + const res = await postCustom('/custom', { + headers: { + 'X-Signature': signature, + 'X-Timestamp': timestamp, + }, + body: PAYLOAD, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ received: true }); + }); + + it('rejects duplicate deliveries of the same event (replay protection)', async () => { + const registry = initWebhookKeyRegistry({ keys: [{ provider: 'custom', secret: 'rotation_secret_08_abcdefghijklmnop' }] }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + const headers = { + 'X-AgenticPay-Signature': signed.signature, + 'X-AgenticPay-Timestamp': signed.timestamp, + 'X-Webhook-Id': 'evt_int_replay_1', + }; + + const first = await postCustom('/custom', { headers, body: PAYLOAD }); + expect(first.status).toBe(200); + + const second = await postCustom('/custom', { headers, body: PAYLOAD }); + expect(second.status).toBe(409); + expect((await second.json())?.error?.message).toBe('Duplicate webhook delivery'); + }); +}); \ No newline at end of file diff --git a/backend/src/middleware/__tests__/webhookVerification.test.ts b/backend/src/middleware/__tests__/webhookVerification.test.ts new file mode 100644 index 00000000..0f07ff0d --- /dev/null +++ b/backend/src/middleware/__tests__/webhookVerification.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Request } from 'express'; +import { + verifyCustomProviderWebhookWithKeys, + configureWebhookVerification, + resetWebhookVerificationConfig, +} from '../webhookVerification.js'; +import { + initWebhookKeyRegistry, + resetWebhookKeyRegistry, +} from '../../services/webhookKeys.js'; +import { generateWebhookSignature } from '../../services/webhooks/verification.js'; + +vi.mock('../../services/stripe.js', () => ({ + constructWebhookEvent: () => { + throw new Error('stripe not exercised in this suite'); + }, +})); + +const PAYLOAD = JSON.stringify({ event: 'payment.captured', data: { id: 'evt_123' } }); +const LEGACY_CUSTOM_SECRET = 'whsec_test_default_custom_secret_key_32_chars_min'; + +function makeReq(overrides: Partial = {}): Request { + return { + headers: {}, + body: {}, + ...overrides, + } as unknown as Request; +} + +describe('webhookVerification middleware (key rotation)', () => { + beforeEach(() => { + initWebhookKeyRegistry(); + resetWebhookVerificationConfig(); + }); + afterEach(() => { + resetWebhookKeyRegistry(); + resetWebhookVerificationConfig(); + }); + + describe('verifyCustomProviderWebhookWithKeys', () => { + it('verifies AgenticPay-signature/timestamp signed with a registered key', () => { + const registry = initWebhookKeyRegistry({ + keys: [{ provider: 'custom', secret: 'rotation_secret_99_abcdefghijklmnop' }], + }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + const req = makeReq({ + headers: { + 'x-agenticpay-signature': signed.signature, + 'x-agenticpay-timestamp': signed.timestamp, + 'x-webhook-id': 'evt_rot_1', + }, + }); + + const result = verifyCustomProviderWebhookWithKeys(req, PAYLOAD); + expect(result.isValid).toBe(true); + expect(result.provider).toBe('custom'); + expect(result.eventId).toBe('evt_rot_1'); + expect(result.payload).toEqual(JSON.parse(PAYLOAD)); + expect(result.keyId).toBeUndefined(); + expect(registry.metrics().verified).toBe(1); + }); + + it('verifies legacy sha256= custom signatures against the registry', () => { + const registry = initWebhookKeyRegistry({ + keys: [{ provider: 'custom', secret: 'a'.repeat(32) }], + }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + const legacyHeader = `sha256=${signed.signature.replace(/^v1=/, '')}`; + const req = makeReq({ + headers: { + 'x-signature': legacyHeader, + 'x-timestamp': signed.timestamp, + }, + }); + const result = verifyCustomProviderWebhookWithKeys(req, PAYLOAD); + expect(result.isValid).toBe(true); + }); + + it('rejects tampered payloads with an error message', () => { + const registry = initWebhookKeyRegistry({ + keys: [{ provider: 'custom', secret: 'b'.repeat(32) }], + }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + const req = makeReq({ + headers: { + 'x-agenticpay-signature': signed.signature, + 'x-agenticpay-timestamp': signed.timestamp, + }, + }); + const result = verifyCustomProviderWebhookWithKeys(req, PAYLOAD + 'tampered'); + expect(result.isValid).toBe(false); + expect(result.error).toMatch(/verification failed/i); + expect(registry.metrics().rejected).toBe(1); + }); + + it('rejects stale timestamps outside the tolerance window', () => { + let clock = 1_700_000_000_000; + const registry = initWebhookKeyRegistry({ + now: () => clock, + keys: [{ provider: 'custom', secret: 'c'.repeat(32) }], + }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + clock += 350_000; + const req = makeReq({ + headers: { + 'x-agenticpay-signature': signed.signature, + 'x-agenticpay-timestamp': signed.timestamp, + }, + }); + const result = verifyCustomProviderWebhookWithKeys(req, PAYLOAD); + expect(result.isValid).toBe(false); + expect(result.error).toMatch(/tolerance/i); + expect(registry.metrics().rejected).toBe(1); + }); + + it('honors toleranceSeconds from middleware configuration', () => { + let clock = 1_700_000_000_000; + const registry = initWebhookKeyRegistry({ + now: () => clock, + keys: [{ provider: 'custom', secret: 'c'.repeat(32) }], + }); + configureWebhookVerification({ toleranceSeconds: 1200 }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + clock += 900_000; + const req = makeReq({ + headers: { + 'x-agenticpay-signature': signed.signature, + 'x-agenticpay-timestamp': signed.timestamp, + }, + }); + const result = verifyCustomProviderWebhookWithKeys(req, PAYLOAD); + expect(result.isValid).toBe(true); + const metrics = registry.metrics(); + expect(metrics.verified).toBe(1); + resetWebhookVerificationConfig(); + }); + + it('falls back to legacy verification when the registry has no custom keys', () => { + initWebhookKeyRegistry(); + const timestamp = new Date().toISOString(); + const signature = generateWebhookSignature(PAYLOAD, LEGACY_CUSTOM_SECRET, timestamp); + const req = makeReq({ + headers: { + 'x-signature': signature, + 'x-timestamp': timestamp, + }, + }); + const result = verifyCustomProviderWebhookWithKeys(req, PAYLOAD); + expect(result.isValid).toBe(true); + }); + + it('forces legacy verification when key rotation is disabled', () => { + initWebhookKeyRegistry({ + keys: [{ provider: 'custom', secret: 'd'.repeat(32) }], + }); + configureWebhookVerification({ useKeyRotation: false }); + const timestamp = new Date().toISOString(); + const signature = generateWebhookSignature(PAYLOAD, LEGACY_CUSTOM_SECRET, timestamp); + const req = makeReq({ + headers: { + 'x-signature': signature, + 'x-timestamp': timestamp, + }, + }); + const result = verifyCustomProviderWebhookWithKeys(req, PAYLOAD); + expect(result.isValid).toBe(true); + }); + + it('accepts array-shaped signature and timestamp headers', () => { + const registry = initWebhookKeyRegistry({ + keys: [{ provider: 'custom', secret: 'e2'.repeat(16) }], + }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + const req = makeReq({ + headers: { + 'x-agenticpay-signature': [signed.signature], + 'x-agenticpay-timestamp': [signed.timestamp], + }, + }); + const result = verifyCustomProviderWebhookWithKeys(req, PAYLOAD); + expect(result.isValid).toBe(true); + expect(registry.metrics().verified).toBe(1); + }); + + it('returns invalid with a clear error when signature headers are absent', () => { + initWebhookKeyRegistry({ + keys: [{ provider: 'custom', secret: 'e'.repeat(32) }], + }); + const req = makeReq({ headers: {} }); + const result = verifyCustomProviderWebhookWithKeys(req, PAYLOAD); + expect(result.isValid).toBe(false); + expect(result.error).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/middleware/cache.js b/backend/src/middleware/cache.js deleted file mode 100644 index a2c50661..00000000 --- a/backend/src/middleware/cache.js +++ /dev/null @@ -1,116 +0,0 @@ -"use strict"; -/** - * cache.ts - * - * cacheControl() — Express middleware factory for Cache-Control + ETag support. - * - * ## Usage - * - * ```ts - * import { cacheControl, CacheTTL } from '../middleware/cache.js'; - * - * router.get('/catalog', cacheControl({ maxAge: CacheTTL.STATIC }), handler); - * ``` - * - * ## What it does - * - * 1. Sets `Cache-Control` on the way out (public/private, max-age, optional - * stale-while-revalidate). - * 2. Computes a strong ETag (SHA-1 of the serialised JSON body, first 16 hex - * chars) and attaches it to the response. - * 3. Handles conditional requests: if the client sends `If-None-Match` with a - * matching ETag the middleware short-circuits and returns 304 Not Modified - * without re-sending the body. - * 4. Only acts on GET and HEAD — POST / PUT / DELETE / PATCH are left alone. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.CacheTTL = void 0; -exports.cacheControl = cacheControl; -var node_crypto_1 = require("node:crypto"); -// ─── Pre-configured TTLs ────────────────────────────────────────────────────── -/** - * Convenience constants for common cache durations. - * - * | Constant | Seconds | Typical use-case | - * |-------------|---------|-----------------------------------------------| - * | STATIC | 300 | Catalog / configuration (rarely changes) | - * | SHORT | 30 | Account balances, recent-state reads | - * | IMMUTABLE | 600 | Confirmed transactions, completed verifications | - * | NONE | 0 | Mutations or user-specific sensitive data | - */ -exports.CacheTTL = { - STATIC: 300, - SHORT: 30, - IMMUTABLE: 600, - NONE: 0, -}; -// ─── Middleware factory ─────────────────────────────────────────────────────── -/** - * Returns an Express middleware that adds `Cache-Control` and `ETag` headers - * to GET/HEAD responses, and responds with **304 Not Modified** when the - * client already holds a fresh copy (via `If-None-Match`). - * - * @example - * // Cache catalog for 5 minutes, allow CDN storage - * router.get('/', cacheControl({ maxAge: CacheTTL.STATIC }), handler); - * - * @example - * // Cache per-user data privately for 30 seconds - * router.get('/me', cacheControl({ maxAge: CacheTTL.SHORT, isPublic: false }), handler); - * - * @example - * // Disable caching explicitly - * router.get('/live', cacheControl({ maxAge: CacheTTL.NONE }), handler); - */ -function cacheControl(options) { - var maxAge = options.maxAge, _a = options.isPublic, isPublic = _a === void 0 ? true : _a, staleWhileRevalidate = options.staleWhileRevalidate; - var cacheControlValue = buildCacheControlHeader(maxAge, isPublic, staleWhileRevalidate); - return function cacheMiddleware(req, res, next) { - // Only intercept cacheable methods - if (req.method !== 'GET' && req.method !== 'HEAD') { - next(); - return; - } - // Intercept res.json so we can inspect the body before it is sent - var originalJson = res.json.bind(res); - res.json = function jsonWithCache(body) { - // Restore res.json immediately to avoid double-wrapping in nested calls - res.json = originalJson; - var bodyStr = JSON.stringify(body); - var etag = computeETag(bodyStr); - res.setHeader('Cache-Control', cacheControlValue); - res.setHeader('ETag', etag); - // Conditional GET — return 304 if client already has this version - var clientETag = req.headers['if-none-match']; - if (clientETag && clientETag === etag) { - res.status(304).end(); - return res; - } - return originalJson(body); - }; - next(); - }; -} -// ─── Helpers ────────────────────────────────────────────────────────────────── -function buildCacheControlHeader(maxAge, isPublic, staleWhileRevalidate) { - if (maxAge === 0) { - return 'no-store'; - } - var directives = [ - isPublic ? 'public' : 'private', - "max-age=".concat(maxAge), - ]; - if (staleWhileRevalidate !== undefined && staleWhileRevalidate > 0) { - directives.push("stale-while-revalidate=".concat(staleWhileRevalidate)); - } - return directives.join(', '); -} -/** - * Generates a strong ETag from the response body string. - * Format: `""` — compact but collision-resistant - * enough for HTTP caching. - */ -function computeETag(body) { - var hash = (0, node_crypto_1.createHash)('sha1').update(body).digest('hex').slice(0, 16); - return "\"".concat(hash, "\""); -} diff --git a/backend/src/middleware/cache.ts b/backend/src/middleware/cache.ts index 331d76ae..7196b4b8 100644 --- a/backend/src/middleware/cache.ts +++ b/backend/src/middleware/cache.ts @@ -1,47 +1,95 @@ -import { createHash, randomUUID } from 'node:crypto'; +/** + * cache.ts + * + * cacheControl() — Express middleware factory for Cache-Control + ETag support + * with optional in-memory / Redis caching and conditional requests. + * + * ## Usage + * + * ```ts + * import { cacheControl, CacheTTL } from '../middleware/cache.js'; + * + * router.get('/catalog', cacheControl({ maxAge: CacheTTL.STATIC }), handler); + * ``` + * + * ## What it does + * + * 1. Sets `Cache-Control` on the way out (public/private, max-age, optional + * stale-while-revalidate). + * 2. Computes a strong ETag (SHA-1 of the serialised JSON body, first 16 hex + * chars) and attaches it to the response. + * 3. Honours `If-None-Match` (including comma lists and `*`) and responds + * `304 Not Modified` (weak comparison per RFC 7232) when the client already + * holds a fresh copy. + * 4. With `inMemory: true`, stores responses in memory (mirrored to Redis when + * available) and serves subsequent requests directly from cache, tagging + * them with `X-Cache: HIT` / `X-Cache: STALE` / `X-Cache: MISS`. + * 5. Error responses (status >= 400) are never cached or etagged. + */ + +import { createHash } from 'node:crypto'; import { Request, Response, NextFunction } from 'express'; import Redis from 'ioredis'; +import { parseIfNoneMatch, weakMatch } from './etag.js'; export interface CacheOptions { + /** Cache lifetime in seconds. 0 means `no-store`. */ maxAge: number; + /** Include `public`/`private` directive (default: public). */ isPublic?: boolean; + /** Seconds stale content may be served while being revalidated. */ staleWhileRevalidate?: number; + /** Serve responses from the in-memory (and Redis) cache. */ inMemory?: boolean; + /** Fixed override for the cache key. */ cacheKey?: string; + /** Do not cache bodies larger than this many bytes (default: 1 MiB). */ + maxBodySize?: number; } export const CacheTTL = { STATIC: 300, SHORT: 30, IMMUTABLE: 600, + LONG: 3600, NONE: 0, } as const; +// ─── Internal types ─────────────────────────────────────────────────────────── + interface CacheEntry { value: T; expiresAt: number; createdAt: number; hitCount: number; + etag: string; } +// ─── MemoryCache ────────────────────────────────────────────────────────────── + class MemoryCache { private store = new Map(); private maxSize: number; - constructor(maxSize = 1000) { + constructor(maxSize = 2000) { this.maxSize = maxSize; } - get(key: string): { value: T; stale: boolean } | null { + get(key: string): { value: T; stale: boolean; etag: string } | null { const entry = this.store.get(key); if (!entry) return null; entry.hitCount++; const stale = Date.now() > entry.expiresAt; - return { value: entry.value as T, stale }; + return { value: entry.value as T, stale, etag: entry.etag }; + } + + has(key: string): boolean { + return this.store.has(key); } - set(key: string, value: unknown, ttlMs: number): void { + set(key: string, value: unknown, ttlMs: number, etag = ''): void { if (this.store.size >= this.maxSize) { + // Evict the oldest entry (Map iteration order = insertion order) const oldest = this.store.entries().next().value; if (oldest) this.store.delete(oldest[0]); } @@ -50,6 +98,7 @@ class MemoryCache { expiresAt: Date.now() + ttlMs, createdAt: Date.now(), hitCount: 0, + etag, }); } @@ -61,6 +110,11 @@ class MemoryCache { this.store.clear(); } + /** All keys currently held by the cache (order = insertion order). */ + keys(): string[] { + return Array.from(this.store.keys()); + } + get size(): number { return this.store.size; } @@ -90,6 +144,8 @@ class MemoryCache { } } +// ─── SingleFlight ───────────────────────────────────────────────────────────── + class SingleFlight { private inFlight = new Map>(); @@ -104,11 +160,14 @@ class SingleFlight { return promise; } + /** Number of operations currently being coalesced. */ get inFlightCount(): number { return this.inFlight.size; } } +// ─── CacheMonitor ───────────────────────────────────────────────────────────── + interface CacheStats { hits: number; misses: number; @@ -153,6 +212,8 @@ class CacheMonitor { } } +// ─── RedisCache ─────────────────────────────────────────────────────────────── + class RedisCache { private client: Redis | null = null; private enabled = false; @@ -238,6 +299,8 @@ class RedisCache { } } +// ─── Shared instances ───────────────────────────────────────────────────────── + const memoryCache = new MemoryCache(2000); const singleFlight = new SingleFlight(); const cacheMonitor = new CacheMonitor(); @@ -245,6 +308,7 @@ const redisCache = new RedisCache(); const CACHE_PREFIX = 'agenticpay:cache:'; const WARMED_KEYS = new Set(); +const DEFAULT_CACHE_BODY_LIMIT = 1_048_576; // 1 MiB export function getCacheMonitor(): CacheMonitor { return cacheMonitor; @@ -262,21 +326,61 @@ export function getRedisCache(): RedisCache { return redisCache; } +// ─── Warming / invalidation ─────────────────────────────────────────────────── + export function warmCache(key: string, fetchFn: () => Promise, ttlMs: number): void { if (WARMED_KEYS.has(key)) return; WARMED_KEYS.add(key); - fetchFn().then((value) => { - memoryCache.set(key, value, ttlMs); - redisCache.set(key, value, ttlMs); - }).catch(() => { - WARMED_KEYS.delete(key); - }); + fetchFn() + .then((value) => { + let etag = ''; + try { + etag = computeETag(JSON.stringify(value)); + } catch { + etag = ''; + } + memoryCache.set(key, value, ttlMs, etag); + redisCache.set(key, value, ttlMs).catch(() => {}); + }) + .catch(() => { + WARMED_KEYS.delete(key); + }); } export function getWarmedKeys(): string[] { return Array.from(WARMED_KEYS); } +/** Escape a glob pattern (`*`, `?`) into a RegExp for memory-key matching. */ +function globToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`); +} + +/** + * Invalidate cached responses whose cache key matches a glob pattern. + * The pattern is relative to the internal `agenticpay:cache:` prefix, + * e.g. `invalidateCache('GET:/api/catalog*')`. + */ +export async function invalidateCache(pattern: string): Promise { + const fullPattern = `${CACHE_PREFIX}${pattern}`; + const re = globToRegExp(fullPattern); + for (const key of memoryCache.keys()) { + if (re.test(key)) { + memoryCache.delete(key); + } + } + await redisCache.invalidate(fullPattern); +} + +/** Drop everything currently held in the in-memory cache. */ +export function clearMemoryCache(): void { + memoryCache.clear(); + WARMED_KEYS.clear(); +} + +// ─── Header / key helpers ───────────────────────────────────────────────────── + function buildCacheControlHeader( maxAge: number, isPublic: boolean, @@ -306,83 +410,126 @@ function buildCacheKey(req: Request, customKey?: string): string { return `${CACHE_PREFIX}${req.method}:${req.originalUrl}`; } +function isCacheableMethod(method: string): boolean { + return method === 'GET' || method === 'HEAD'; +} + +/** + * Extract a single, quoted ETag from the current response writer's headers. + * Returns '' when none has been set. + */ +function currentETag(res: Response): string { + const header = res.getHeader('ETag'); + const value = Array.isArray(header) ? header[0] : header; + return typeof value === 'string' ? value : ''; +} + +/** Reads the ETag of a stored response, or computes one for the given body. */ +function etagFor(bodyStr: string, res: Response): string { + return currentETag(res) || computeETag(bodyStr); +} + +// ─── cacheControl middleware ────────────────────────────────────────────────── + export function cacheControl(options: CacheOptions) { - const { maxAge, isPublic = true, staleWhileRevalidate, inMemory = false, cacheKey } = options; + const { + maxAge, + isPublic = true, + staleWhileRevalidate, + inMemory = false, + cacheKey, + maxBodySize = DEFAULT_CACHE_BODY_LIMIT, + } = options; const cacheControlValue = buildCacheControlHeader(maxAge, isPublic, staleWhileRevalidate); const ttlMs = maxAge * 1000; return function cacheMiddleware(req: Request, res: Response, next: NextFunction): void { - if (req.method !== 'GET' && req.method !== 'HEAD') { + if (!isCacheableMethod(req.method)) { next(); return; } - if (inMemory) { - const key = buildCacheKey(req, cacheKey); + // ── Header-only mode (no response caching) ───────────────────────────── + if (!inMemory) { + const originalJson = res.json.bind(res); + res.json = function jsonWithCache(body: unknown): Response { + res.json = originalJson; - const cached = memoryCache.get(key); - if (cached) { - if (!cached.stale) { - cacheMonitor.recordHit(); - res.setHeader('X-Cache', 'HIT'); - res.setHeader('Cache-Control', cacheControlValue); - return res.json(cached.value); + if (res.statusCode >= 400) { + res.setHeader('Cache-Control', 'no-store'); + return originalJson(body); } - } - cacheMonitor.recordMiss(); + const bodyStr = JSON.stringify(body); + const etag = etagFor(bodyStr, res); - singleFlight.execute(key, async () => { - const originalJson = res.json.bind(res); - let capturedBody: unknown; + res.setHeader('Cache-Control', cacheControlValue); + res.setHeader('ETag', etag); - res.json = function jsonWithCache(body: unknown): Response { - res.json = originalJson; - capturedBody = body; - - const bodyStr = JSON.stringify(body); - const etag = computeETag(bodyStr); + if (clientHasFreshCopy(req, etag)) { + res.status(304).end(); + return res; + } - res.setHeader('Cache-Control', cacheControlValue); - res.setHeader('ETag', etag); - res.setHeader('X-Cache', 'MISS'); + return originalJson(body); + }; + next(); + return; + } - memoryCache.set(key, body, ttlMs); - redisCache.set(key, body, ttlMs); - cacheMonitor.recordSet(); + // ── In-memory caching mode ───────────────────────────────────────────── + const key = buildCacheKey(req, cacheKey); + const entry = memoryCache.get(key); - const clientETag = req.headers['if-none-match']; - if (clientETag && clientETag === etag) { - res.status(304).end(); - return res; - } + if (entry && !entry.stale) { + cacheMonitor.recordHit(); + serveFromCache(req, res, entry.etag, entry.value, cacheControlValue, 'HIT'); + return; + } - return originalJson(body); - }; - - next(); - await new Promise((resolve) => { - res.on('finish', () => resolve()); - }); - return capturedBody; - }).catch(() => {}); + if (entry && entry.stale && (staleWhileRevalidate ?? 0) > 0) { + cacheMonitor.recordHit(); + serveFromCache(req, res, entry.etag, entry.value, cacheControlValue, 'STALE'); return; } - const originalJson = res.json.bind(res); + cacheMonitor.recordMiss(); - res.json = function jsonWithCache(body: unknown): Response { + const originalJson = res.json.bind(res); + res.json = function jsonWithCacheAndStore(body: unknown): Response { res.json = originalJson; + const statusCode = res.statusCode; const bodyStr = JSON.stringify(body); - const etag = computeETag(bodyStr); + + // Never cache or etag error responses + if (statusCode >= 400) { + res.setHeader('Cache-Control', 'no-store'); + return originalJson(body); + } + + // Skip responses that exceed the configured body limit + if (bodyStr.length > maxBodySize) { + return originalJson(body); + } + + const etag = etagFor(bodyStr, res); res.setHeader('Cache-Control', cacheControlValue); res.setHeader('ETag', etag); + res.setHeader('X-Cache', 'MISS'); + + // Store after the response has been fully written so interrupted + // responses never poison the cache. + res.on('finish', () => { + memoryCache.set(key, body, ttlMs, etag); + redisCache.set(key, body, ttlMs).catch(() => {}); + cacheMonitor.recordSet(); + }); - const clientETag = req.headers['if-none-match']; - if (clientETag && clientETag === etag) { + // Fast path: the client already holds this exact representation + if (clientHasFreshCopy(req, etag)) { res.status(304).end(); return res; } @@ -394,6 +541,36 @@ export function cacheControl(options: CacheOptions) { }; } +/** True when the client's If-None-Match header matches the computed ETag. */ +function clientHasFreshCopy(req: Request, etag: string): boolean { + return parseIfNoneMatch(req.headers['if-none-match'] as string).some( + (tag) => tag === '*' || weakMatch(tag, etag), + ); +} + +/** Serve a previously stored response directly (no handler execution). */ +function serveFromCache( + req: Request, + res: Response, + etag: string, + value: unknown, + cacheControlValue: string, + xCache: 'HIT' | 'STALE', +): void { + res.setHeader('Cache-Control', cacheControlValue); + res.setHeader('X-Cache', xCache); + if (etag) res.setHeader('ETag', etag); + + if (etag && clientHasFreshCopy(req, etag)) { + res.status(304).end(); + return; + } + + res.json(value); +} + +// ─── Periodic maintenance ───────────────────────────────────────────────────── + setInterval(() => { memoryCache.evictExpired(); -}, 60_000); +}, 60_000).unref(); \ No newline at end of file diff --git a/backend/src/middleware/circuit-breaker.ts b/backend/src/middleware/circuit-breaker.ts index 816e4e93..aed23d09 100644 --- a/backend/src/middleware/circuit-breaker.ts +++ b/backend/src/middleware/circuit-breaker.ts @@ -1,284 +1,116 @@ -import type { NextFunction, Request, Response } from 'express'; - -type CircuitState = 'closed' | 'open' | 'half_open'; - -interface CircuitBreakerConfig { - failureThreshold: number; - successThreshold: number; - timeoutMs: number; - halfOpenMaxCalls: number; - requestTimeoutMs: number; -} - -interface CircuitBreakerMetrics { - totalCalls: number; - successfulCalls: number; - failedCalls: number; - timeoutCalls: number; - rejectedCalls: number; - lastFailureAt?: number; - lastSuccessAt?: number; - openedAt?: number; - halfOpenAttempts: number; -} - -interface CircuitBreakerState { - state: CircuitState; - failures: number; - successes: number; - halfOpenCalls: number; - lastFailureAt?: number; - openedAt?: number; - metrics: CircuitBreakerMetrics; -} - -interface CircuitBreakerEntry { - config: CircuitBreakerConfig; - state: CircuitBreakerState; -} - -const DEFAULT_CONFIG: CircuitBreakerConfig = { - failureThreshold: 5, - successThreshold: 2, - timeoutMs: 60_000, - halfOpenMaxCalls: 3, - requestTimeoutMs: 10_000, -}; - -const circuits = new Map(); - -function getOrCreate(name: string, configOverride?: Partial): CircuitBreakerEntry { - const existing = circuits.get(name); - if (existing) return existing; - const config = { ...DEFAULT_CONFIG, ...configOverride }; - const state: CircuitBreakerState = { - state: 'closed', - failures: 0, - successes: 0, - halfOpenCalls: 0, - metrics: { - totalCalls: 0, - successfulCalls: 0, - failedCalls: 0, - timeoutCalls: 0, - rejectedCalls: 0, - halfOpenAttempts: 0, - }, - }; - const entry: CircuitBreakerEntry = { config, state }; - circuits.set(name, entry); - return entry; -} - -function onSuccess(name: string): void { - const entry = circuits.get(name); - if (!entry) return; - const { state, config } = entry; - - state.metrics.totalCalls++; - state.metrics.successfulCalls++; - state.metrics.lastSuccessAt = Date.now(); - - if (state.state === 'half_open') { - state.successes += 1; - if (state.successes >= config.successThreshold) { - state.state = 'closed'; - state.failures = 0; - state.successes = 0; - state.halfOpenCalls = 0; - } - } else if (state.state === 'closed') { - state.failures = Math.max(0, state.failures - 1); - } -} - -function onFailure(name: string): void { - const entry = circuits.get(name); - if (!entry) return; - const { state, config } = entry; - - state.failures += 1; - state.lastFailureAt = Date.now(); - state.metrics.totalCalls++; - state.metrics.failedCalls++; - - if (state.state === 'half_open' || state.failures >= config.failureThreshold) { - state.state = 'open'; - state.openedAt = Date.now(); - state.metrics.openedAt = Date.now(); - state.halfOpenCalls = 0; - state.successes = 0; - } -} - -function onTimeout(name: string): void { - const entry = circuits.get(name); - if (!entry) return; - entry.state.metrics.timeoutCalls++; - onFailure(name); -} - -function shouldAllow(name: string): boolean { - const entry = circuits.get(name); - if (!entry) return true; - const { state, config } = entry; - - if (state.state === 'closed') return true; - - if (state.state === 'open') { - const elapsed = Date.now() - (state.openedAt ?? 0); - if (elapsed >= config.timeoutMs) { - state.state = 'half_open'; - state.successes = 0; - state.halfOpenCalls = 0; - state.metrics.halfOpenAttempts++; - return true; - } - state.metrics.rejectedCalls++; - return false; - } - - if (state.halfOpenCalls < config.halfOpenMaxCalls) { - state.halfOpenCalls += 1; - return true; - } - - state.metrics.rejectedCalls++; - return false; -} - -export function circuitBreaker(name: string, config: Partial = {}) { - getOrCreate(name, config); +import type { NextFunction, Request, Response, RequestHandler } from 'express'; +import type { CircuitBreakerConfig, CircuitBreakerSnapshot } from '../services/circuitBreaker.js'; +import { CircuitBreakerRegistry } from '../services/circuitBreakerRegistry.js'; + +export { CircuitBreakerError } from '../services/circuitBreaker.js'; +export type { CircuitBreakerConfig, CircuitBreakerState, CircuitBreakerSnapshot } from '../services/circuitBreaker.js'; + +/** + * The registry that backs the module-level name-based API (`withCircuitBreaker`, + * `circuitBreaker`, `getCircuitState`, ...). Existing callers reference circuits + * by a stable name and expect this singleton to hold them. Use the exported + * `circuitBreakerRegistry` object to introspect or reset from elsewhere. + */ +export const circuitBreakerRegistry = new CircuitBreakerRegistry(); + +export function withCircuitBreaker( + name: string, + fn: () => Promise, + fallback?: () => Promise, + configOverride?: Partial, +): Promise { + const breaker = circuitBreakerRegistry.get(name, configOverride); + return breaker.protect(fn, fallback); +} + +/** + * Express middleware guarding a route by a named circuit. + * + * When the circuit is open the request is short-circuited with a 503 and the + * management-friendly `retryAfterMs` hint. Otherwise the upstream handler runs + * and its outcome is recorded as a success/failure based on the response status + * after the response finishes. This avoids the fragile `res.json` monkey-patch + * approach and propagates the outcome even for handlers that stream or never + * call `res.json`. + */ +export function circuitBreaker(name: string, config: Partial = {}): RequestHandler { + const breaker = circuitBreakerRegistry.get(name, config); return (req: Request, res: Response, next: NextFunction): void => { - if (!shouldAllow(name)) { + if (!breaker.isCallPermitted()) { + const snapshot = breaker.snapshot(); res.status(503).json({ error: { code: 'CIRCUIT_OPEN', message: `Service ${name} is temporarily unavailable. Circuit breaker is open.`, status: 503, - retryAfterMs: getRetryAfterMs(name), + retryAfterMs: retryAfterMs(snapshot), }, }); return; } - const originalJson = res.json.bind(res); - res.json = (body: unknown) => { + let recorded = false; + const trackOutcome = () => { + if (recorded) return; + recorded = true; if (res.statusCode >= 500) { - onFailure(name); + breaker.recordFailure(new Error(`HTTP ${res.statusCode} from ${name}`)); } else { - onSuccess(name); + breaker.recordSuccess(); } - return originalJson(body); }; + res.once('finish', trackOutcome); + res.once('close', trackOutcome); next(); }; } -function getRetryAfterMs(name: string): number { - const entry = circuits.get(name); - if (!entry || !entry.state.openedAt) return entry?.config.timeoutMs ?? DEFAULT_CONFIG.timeoutMs; - const elapsed = Date.now() - entry.state.openedAt; - return Math.max(0, entry.config.timeoutMs - elapsed); +function retryAfterMs(snapshot: CircuitBreakerSnapshot): number { + if (typeof snapshot.openedAt !== 'number') return snapshot.config.waitDurationInOpenState; + const elapsed = Date.now() - snapshot.openedAt; + return Math.max(0, snapshot.config.waitDurationInOpenState - elapsed); } -export function getCircuitState(name: string) { - const entry = circuits.get(name); - if (!entry) return null; - return { - name, - state: entry.state.state, - failures: entry.state.failures, - successes: entry.state.successes, - halfOpenCalls: entry.state.halfOpenCalls, - lastFailureAt: entry.state.lastFailureAt, - openedAt: entry.state.openedAt, - config: entry.config, - metrics: entry.state.metrics, +/** + * Wrap a RouteHandler so it runs inside the breaker and its resolved/rejected + * outcome is recorded. Useful when a circuit should guard a single async handler + * rather than the whole downstream chain. + */ +export function circuitBroken( + name: string, + handler: RequestHandler, + config: Partial = {}, +): RequestHandler { + const breaker = circuitBreakerRegistry.get(name, config); + return (req: Request, res: Response, next: NextFunction): void => { + const run = () => Promise.resolve().then(() => handler(req, res, next)); + breaker.protect(run).catch(next); }; } -export function getAllCircuits() { - return Array.from(circuits.entries()).map(([name, entry]) => ({ - name, - state: entry.state.state, - failures: entry.state.failures, - successes: entry.state.successes, - halfOpenCalls: entry.state.halfOpenCalls, - lastFailureAt: entry.state.lastFailureAt, - openedAt: entry.state.openedAt, - config: entry.config, - metrics: entry.state.metrics, - })); +/** + * Return a serialisable snapshot for a named circuit (the same shape consumed by + * the management routes via `res.json`), or `null` when no such circuit exists. + */ +export function getCircuitState(name: string): CircuitBreakerSnapshot | null { + const breaker = circuitBreakerRegistry.getIfPresent(name); + if (!breaker) return null; + return breaker.snapshot(); } -export function resetCircuit(name: string): boolean { - const entry = circuits.get(name); - if (!entry) return false; - entry.state = { - state: 'closed', - failures: 0, - successes: 0, - halfOpenCalls: 0, - metrics: { - totalCalls: 0, - successfulCalls: 0, - failedCalls: 0, - timeoutCalls: 0, - rejectedCalls: 0, - halfOpenAttempts: 0, - }, - }; - return true; +/** Return serialisable snapshots for every registered circuit. */ +export function getAllCircuits(): CircuitBreakerSnapshot[] { + return circuitBreakerRegistry.snapshots(); } -export async function withCircuitBreaker( - name: string, - fn: () => Promise, - fallback?: () => Promise, - configOverride?: Partial, -): Promise { - const entry = getOrCreate(name, configOverride); - const { state, config } = entry; - - if (!shouldAllow(name)) { - if (fallback) { - return fallback(); - } - throw new CircuitBreakerError(name, `Circuit breaker is open for ${name}`); - } - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - onTimeout(name); - reject(new CircuitBreakerError(name, `Request to ${name} timed out after ${config.requestTimeoutMs}ms`, true)); - }, config.requestTimeoutMs); - }); - - try { - const result = await Promise.race([fn(), timeoutPromise]); - onSuccess(name); - return result; - } catch (error) { - if (error instanceof CircuitBreakerError) throw error; - onFailure(name); - if (fallback) { - return fallback(); - } - throw error; - } +export function resetCircuit(name: string): boolean { + return circuitBreakerRegistry.reset(name); } -export class CircuitBreakerError extends Error { - serviceName: string; - isTimeout: boolean; - - constructor(serviceName: string, message: string, isTimeout = false) { - super(message); - this.name = 'CircuitBreakerError'; - this.serviceName = serviceName; - this.isTimeout = isTimeout; - } +export function resetAllCircuits(): void { + circuitBreakerRegistry.resetAll(); } + +export type { Request, Response, NextFunction }; diff --git a/backend/src/middleware/cors.ts b/backend/src/middleware/cors.ts new file mode 100644 index 00000000..70a0cab2 --- /dev/null +++ b/backend/src/middleware/cors.ts @@ -0,0 +1,240 @@ +/** + * middleware/cors.ts — CORS middleware backed by a dynamic origin whitelist. + * + * Resolves every request against the shared `CORSOriginPolicy` in + * `services/cors.js`, so allowlist changes made at runtime (via the + * management router, `addAllowedOrigin()`/`removeAllowedOrigin()`, or an + * async loader refresh) take effect on the very next request — no redeploy. + * + * ## Behaviour + * + * - No `Origin` header → passes through, sets `Vary: Origin`. + * - Allowed origin → reflects the concrete origin (or `*` when the + * allowlist is open and credentials are disabled), sets `Vary: Origin`. + * - Denied origin → passes through WITHOUT any `Access-Control-*` + * header, so the browser blocks the response. + * - Preflight → `OPTIONS` + `Access-Control-Request-Method` is + * answered with `204` and the negotiated headers, or handed to the app + * when `preflightContinue` is set. + * - Credentials → never combined with a bare `Access-Control-Allow- + * Origin: *`; in open mode the concrete origin is always reflected so + * credentialed requests keep working. + * - Error statuses → the headers are attached regardless of downstream + * status (CORS is transport, not content, policy). + * + * ## Usage + * + * ```ts + * import { createCorsMiddleware } from '../middleware/cors.js'; + * import { getCorsPolicy } from '../services/cors.js'; + * + * getCorsPolicy().set(['https://app.example.com', 'https://*.internal.example.com']); + * + * app.use(createCorsMiddleware({ + * credentials: true, + * methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + * allowedHeaders: ['Content-Type', 'Authorization'], + * })); + * ``` + */ + +import { NextFunction, Request, Response, RequestHandler } from 'express'; +import { + getCorsPolicy, + recordPreflight, +} from '../services/cors.js'; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export interface CorsMiddlewareOptions { + /** Seed the shared policy with these origins/patterns (at mount time). */ + allowedOrigins?: string[]; + /** Reflect `Access-Control-Allow-Credentials`. Overrides policy default. */ + credentials?: boolean; + /** Methods admitted in preflight `Access-Control-Allow-Methods`. */ + methods?: string[]; + /** Whitelist for `Access-Control-Request-Headers`; undefined = echo all. */ + allowedHeaders?: string[]; + /** Headers browser scripts may read (`Access-Control-Expose-Headers`). */ + exposedHeaders?: string[]; + /** Cached preflight validity for `Access-Control-Max-Age` (seconds). */ + maxAge?: number; + /** Status for answered preflights (default: 204). */ + optionsSuccessStatus?: number; + /** Hand preflights to the app instead of answering them (default: false). */ + preflightContinue?: boolean; +} + +export const DEFAULT_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; +export const DEFAULT_ALLOWED_HEADERS = ['Content-Type', 'Authorization']; + +// Header names. Node lowercases incoming header keys on `req.headers`, so +// request lookups use lowercase constants while response setters use the +// canonical title-cased spelling. +const IN = { + origin: 'origin', + requestMethod: 'access-control-request-method', + requestHeaders: 'access-control-request-headers', + requestPrivateNetwork: 'access-control-request-private-network', +}; + +const A = { + allowOrigin: 'Access-Control-Allow-Origin', + allowCredentials: 'Access-Control-Allow-Credentials', + allowMethods: 'Access-Control-Allow-Methods', + allowHeaders: 'Access-Control-Allow-Headers', + allowPrivateNetwork: 'Access-Control-Allow-Private-Network', + exposeHeaders: 'Access-Control-Expose-Headers', + maxAge: 'Access-Control-Max-Age', +}; + +// ─── Header helpers ─────────────────────────────────────────────────────────── + +function setHeader(res: Response, name: string, value: string): void { + res.setHeader(name, value); +} + +/** Append a field to `Vary` without clobbering existing values. */ +function appendVary(res: Response, field: string): void { + const existing = res.getHeader('Vary'); + let next: string; + if (existing === undefined) { + next = field; + } else if (Array.isArray(existing)) { + next = existing.concat(field).join(', '); + } else { + next = `${String(existing)}, ${field}`; + } + res.setHeader('Vary', next); +} + +function firstHeader(value: unknown): string | undefined { + if (Array.isArray(value)) return value[0] as string | undefined; + if (typeof value === 'string') return value; + return undefined; +} + +/** The request's first `Origin` value, trimmed of surrounding whitespace. */ +function parseRequestOrigin(req: Request): string | undefined { + const raw = firstHeader(req.headers[IN.origin]); + if (!raw) return undefined; + const [origin] = raw.split(/\s+/); + return origin?.trim() || undefined; +} + +function parseRequestHeaders(value: unknown): string[] { + const raw = firstHeader(value); + if (!raw) return []; + const seen = new Set(); + const out: string[] = []; + for (const part of raw.split(',')) { + const header = part.trim(); + if (header && !seen.has(header.toLowerCase())) { + seen.add(header.toLowerCase()); + out.push(header); + } + } + return out; +} + +function isPreflight(req: Request): boolean { + return req.method === 'OPTIONS' && Boolean(req.headers[IN.requestMethod]); +} + +// ─── Middleware ─────────────────────────────────────────────────────────────── + +export function createCorsMiddleware(options: CorsMiddlewareOptions = {}): RequestHandler { + const credentials = options.credentials ?? getCorsPolicy().credentials; + const methods = new Set((options.methods ?? DEFAULT_METHODS).map((m) => m.toUpperCase().trim())); + const configuredHeaders = options.allowedHeaders + ? new Set(options.allowedHeaders.map((h) => h.toLowerCase().trim())) + : undefined; + const exposedHeaders = options.exposedHeaders ?? []; + const optionsSuccessStatus = options.optionsSuccessStatus ?? 204; + const preflightContinue = options.preflightContinue ?? false; + const maxAge = options.maxAge; + + if (options.allowedOrigins) { + getCorsPolicy().set(options.allowedOrigins); + } + + function allowOriginFor(origin: string, policyOpen: boolean): string { + // Open mode + credentials must reflect the concrete origin — a bare `*` + // with `Access-Control-Allow-Credentials` is rejected by browsers. + if (policyOpen && !credentials) return '*'; + return origin; + } + + return function corsMiddleware(req: Request, res: Response, next: NextFunction): void { + const policy = getCorsPolicy(); + const requestOrigin = parseRequestOrigin(req); + + if (!requestOrigin) { + appendVary(res, 'Origin'); + next(); + return; + } + + const allowed = policy.isAllowed(requestOrigin); + + if (isPreflight(req)) { + recordPreflight(allowed); + appendVary(res, 'Origin'); + appendVary(res, 'Access-Control-Request-Method'); + appendVary(res, 'Access-Control-Request-Headers'); + + if (allowed) { + setHeader(res, A.allowOrigin, allowOriginFor(requestOrigin, policy.wildcard)); + + if (credentials) { + setHeader(res, A.allowCredentials, 'true'); + } + + const requestedMethod = String(req.headers[IN.requestMethod]).toUpperCase().trim(); + if (requestedMethod && methods.has(requestedMethod)) { + setHeader(res, A.allowMethods, requestedMethod); + } + + const requestedHeaders = parseRequestHeaders(req.headers[IN.requestHeaders]); + const finalHeaders = configuredHeaders + ? requestedHeaders.filter((h) => configuredHeaders.has(h.toLowerCase())) + : requestedHeaders; + if (finalHeaders.length > 0) { + setHeader(res, A.allowHeaders, finalHeaders.join(', ')); + } + + if (req.headers[IN.requestPrivateNetwork] === 'true') { + setHeader(res, A.allowPrivateNetwork, 'true'); + } + + if (typeof maxAge === 'number') { + setHeader(res, A.maxAge, String(maxAge)); + } + } + + if (preflightContinue) { + next(); + return; + } + res.status(optionsSuccessStatus).end(); + return; + } + + appendVary(res, 'Origin'); + + if (allowed) { + setHeader(res, A.allowOrigin, allowOriginFor(requestOrigin, policy.wildcard)); + if (credentials) { + setHeader(res, A.allowCredentials, 'true'); + } + if (exposedHeaders.length > 0) { + setHeader(res, A.exposeHeaders, exposedHeaders.join(', ')); + } + } + + next(); + }; +} + +/** Descriptive alias matching the `cors` package import name. */ +export const cors = createCorsMiddleware; \ No newline at end of file diff --git a/backend/src/middleware/etag.ts b/backend/src/middleware/etag.ts index d0ecddac..63507b17 100644 --- a/backend/src/middleware/etag.ts +++ b/backend/src/middleware/etag.ts @@ -5,13 +5,14 @@ * via If-None-Match headers, returning 304 Not Modified when appropriate. * * Features: - * - Content-based ETag generation (SHA-256 hash) + * - Content-based ETag generation (configurable hash algorithm) * - Weak ETags for large payloads to reduce hashing cost - * - If-None-Match / If-Match header handling + * - If-None-Match handling: comma-separated lists and `*` wildcard + * - Weak/strong comparison semantics per RFC 7232 * - 304 Not Modified responses for matching ETags - * - Cache bypass for authenticated mutation requests - * - Collision-resistant hashing with configurable algorithm - * - Per-endpoint Cache-Control header integration + * - Cache bypass for authenticated requests (Bearer tokens and API keys) + * - Error responses (status >= 400) are never tagged or short-circuited + * - Composes safely with other ETag-emitting middleware (e.g. cacheControl) */ import { createHash } from 'node:crypto'; @@ -30,8 +31,6 @@ export interface ETagOptions { maxBodySize?: number; /** Bypass ETag for authenticated requests (default: false) */ bypassAuthenticated?: boolean; - /** Custom function to derive the cache key from a request */ - cacheKeyFn?: (req: Request) => string; } export interface ETagMetrics { @@ -47,7 +46,7 @@ export interface ETagMetrics { const DEFAULT_ALGORITHM = 'sha256'; const DEFAULT_WEAK_THRESHOLD = 1_048_576; // 1 MiB const DEFAULT_MAX_BODY_SIZE = 10_485_760; // 10 MiB -const HASH_DIGEST_LENGTH = 32; // hex chars to keep from the hash +const HASH_DIGEST_LENGTH = 32; // hex chars retained from the hash // ─── Metrics ────────────────────────────────────────────────────────────────── @@ -121,6 +120,11 @@ export function strongMatch(etagA: string, etagB: string): boolean { // ─── Middleware ─────────────────────────────────────────────────────────────── +/** True when a request carries credentials of any kind. */ +function carriesCredentials(req: Request): boolean { + return Boolean(req.headers.authorization || req.headers['x-api-key']); +} + /** * Express middleware that adds ETag headers and handles conditional requests. * @@ -129,6 +133,8 @@ export function strongMatch(etagA: string, etagB: string): boolean { * - Returns 304 Not Modified when the client's If-None-Match matches * * Mutations (POST/PUT/PATCH/DELETE) are passed through without ETag logic. + * If another middleware (or route) already set an ETag header, this middleware + * honours it instead of generating a conflicting one. */ export function etag(options: ETagOptions = {}) { const { @@ -137,7 +143,6 @@ export function etag(options: ETagOptions = {}) { weakThresholdBytes = DEFAULT_WEAK_THRESHOLD, maxBodySize = DEFAULT_MAX_BODY_SIZE, bypassAuthenticated = false, - cacheKeyFn, } = options; return function etagMiddleware(req: Request, res: Response, next: NextFunction): void { @@ -148,7 +153,7 @@ export function etag(options: ETagOptions = {}) { } // Optionally bypass for authenticated requests - if (bypassAuthenticated && req.headers.authorization) { + if (bypassAuthenticated && carriesCredentials(req)) { metrics.bypassed++; next(); return; @@ -159,6 +164,16 @@ export function etag(options: ETagOptions = {}) { res.json = function etagJson(body: unknown): Response { res.json = originalJson; + // Never tag or short-circuit error responses + if (res.statusCode >= 400) { + metrics.bypassed++; + return originalJson(body); + } + + // If an upstream middleware already tagged this response, defer to it + const existingHeader = res.getHeader('ETag'); + const existing = Array.isArray(existingHeader) ? existingHeader[0] : existingHeader; + const bodyStr = JSON.stringify(body); // Skip ETag for oversized responses @@ -170,8 +185,14 @@ export function etag(options: ETagOptions = {}) { // Determine if we should use a weak ETag const useWeak = weak || bodyStr.length > weakThresholdBytes; - const tag = generateETag(bodyStr, algorithm, useWeak); - metrics.generated++; + const tag = + typeof existing === 'string' && existing.length > 0 + ? existing + : generateETag(bodyStr, algorithm, useWeak); + + if (typeof existing !== 'string' || existing.length === 0) { + metrics.generated++; + } res.setHeader('ETag', tag); @@ -206,4 +227,4 @@ export const defaultETag = () => etag(); * Convenience preset: ETag with authenticated-request bypass. */ export const publicETag = () => - etag({ bypassAuthenticated: true, weak: false }); + etag({ bypassAuthenticated: true, weak: false }); \ No newline at end of file diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index cea57224..10fae8d3 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -15,9 +15,10 @@ export { sessionMiddleware } from './session.js'; export { slaTrackingMiddleware } from './slaTracking.js'; export { traceMiddleware, TRACE_ID_HEADER } from './trace.js'; export { cacheControlNoStore, CACHE_NOSTORE_HEADER, VARY_HEADER } from './cache-control.js'; +export { createCorsMiddleware, cors, type CorsMiddlewareOptions, DEFAULT_METHODS, DEFAULT_ALLOWED_HEADERS } from './cors.js'; export { validate } from './validate.js'; export { versionMiddleware } from './versioning.js'; -export { verifyWebhook, webhookVerifiers, rawBodyCapture, type WebhookVerificationConfig } from './webhookVerification.js'; +export { verifyWebhookProvider, verifyCustomProviderWebhookWithKeys, webhookVerifiers, captureRawBody, webhookJsonParser, configureWebhookVerification, resetWebhookVerificationConfig, type WebhookVerificationConfig } from './webhookVerification.js'; export { composeMiddleware, type MiddlewareFunction, type MiddlewareChain } from './compose.js'; export { tokenAuthMiddleware } from './token-auth.js'; export { hmacAuthMiddleware, invalidateKeyCache, HEADER_SIGNATURE, HEADER_TIMESTAMP, HEADER_NONCE } from './hmac-auth.js'; diff --git a/backend/src/middleware/webhookVerification.ts b/backend/src/middleware/webhookVerification.ts index edc7e0d5..8ced87d1 100644 --- a/backend/src/middleware/webhookVerification.ts +++ b/backend/src/middleware/webhookVerification.ts @@ -11,6 +11,7 @@ import { verifyCustomProviderWebhook, type ProviderVerificationResult, } from '../services/webhooks/providers.js'; +import { getWebhookKeyRegistry } from '../services/webhookKeys.js'; import { isReplayEvent } from '../services/webhooks/replay.js'; import { storeWebhookPayload } from '../services/webhooks/audit.js'; import { createModuleLogger } from './logger.js'; @@ -18,6 +19,30 @@ import { AppError } from './errorHandler.js'; const webhookLog = createModuleLogger('webhooks'); +export interface WebhookVerificationConfig { + useKeyRotation?: boolean; + toleranceSeconds?: number; +} + +export const webhookVerificationConfig: WebhookVerificationConfig = { + useKeyRotation: true, +}; + +export function configureWebhookVerification(config: WebhookVerificationConfig): WebhookVerificationConfig { + if (config.useKeyRotation !== undefined) { + webhookVerificationConfig.useKeyRotation = config.useKeyRotation; + } + if (config.toleranceSeconds !== undefined) { + webhookVerificationConfig.toleranceSeconds = config.toleranceSeconds; + } + return webhookVerificationConfig; +} + +export function resetWebhookVerificationConfig(): void { + webhookVerificationConfig.useKeyRotation = true; + webhookVerificationConfig.toleranceSeconds = undefined; +} + declare global { namespace Express { interface Request { @@ -41,11 +66,63 @@ export const webhookJsonParser = express.json({ type ProviderVerifier = (req: Request, rawBody: string) => ProviderVerificationResult; +const CUSTOM_SIGNATURE_HEADERS = ['x-agenticpay-signature', 'x-signature'] as const; +const CUSTOM_TIMESTAMP_HEADERS = ['x-agenticpay-timestamp', 'x-timestamp'] as const; + +function firstHeader(req: Request, names: readonly string[]): string | undefined { + for (const name of names) { + const value = req.headers[name]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') { + return value[0]; + } + } + return undefined; +} + +export function verifyCustomProviderWebhookWithKeys(req: Request, rawBody: string): ProviderVerificationResult { + const registry = getWebhookKeyRegistry(); + const signature = firstHeader(req, CUSTOM_SIGNATURE_HEADERS); + const timestamp = firstHeader(req, CUSTOM_TIMESTAMP_HEADERS); + + if (webhookVerificationConfig.useKeyRotation && registry.hasKeysForProvider('custom') && signature && timestamp) { + const result = registry.verify({ + signature, + timestamp, + body: rawBody, + provider: 'custom', + keyId: typeof req.headers['x-webhook-key-id'] === 'string' ? req.headers['x-webhook-key-id'] : undefined, + toleranceSeconds: webhookVerificationConfig.toleranceSeconds, + }); + + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + payload = rawBody; + } + + return { + isValid: result.isValid, + provider: 'custom', + eventId: (req.headers['x-webhook-id'] as string) || `custom_${Date.now()}`, + timestamp: new Date(result.timestamp), + body: rawBody, + error: result.isValid ? undefined : result.error, + payload, + }; + } + + return verifyCustomProviderWebhook(req, rawBody); +} + const providerVerifiers: Record = { stripe: verifyStripeProviderWebhook, paypal: verifyPaypalProviderWebhook, github: verifyGithubProviderWebhook, - custom: verifyCustomProviderWebhook, + custom: verifyCustomProviderWebhookWithKeys, }; export function verifyWebhookProvider(provider: WebhookProvider) { diff --git a/backend/src/routes/cors.ts b/backend/src/routes/cors.ts new file mode 100644 index 00000000..fdf051fb --- /dev/null +++ b/backend/src/routes/cors.ts @@ -0,0 +1,139 @@ +/** + * routes/cors.ts — Runtime CORS policy management. + * + * Admin surface for the dynamic origin allowlist. Changes apply to the very + * next request through the shared `CORSOriginPolicy` — no redeploy needed. + * + * Security note: this router mutates cross-origin policy for the whole API. + * Mount it behind an auth/ACL middleware in production (the IP-allowlist and + * CORS routers share this convention in this codebase). + */ + +import express from 'express'; +import { z } from 'zod'; +import { + getCorsPolicy, + CorsPolicyError, + getCorsMetrics, +} from '../services/cors.js'; + +const router = express.Router(); + +const allowedOriginsSchema = z + .array(z.string(), { errorMap: () => ({ message: 'expected an array of origin strings' }) }) + .max(500); + +const upsertSchema = z.object({ + origin: z.string({ errorMap: () => ({ message: 'origin must be a string' }) }).trim().min(1), +}); + +const configSchema = z.object({ + allowedOrigins: allowedOriginsSchema, + allowCredentials: z.boolean().optional(), +}); + +function policyError(res: express.Response, err: unknown): void { + if (err instanceof z.ZodError) { + res.status(400).json({ + error: { code: 'VALIDATION_FAILED', message: err.issues[0]?.message ?? 'Invalid input', status: 400 }, + }); + return; + } + if (err instanceof CorsPolicyError) { + res.status(400).json({ + error: { code: err.code, message: err.message, status: 400 }, + }); + return; + } + res.status(500).json({ + error: { code: 'INTERNAL_ERROR', message: 'Failed to update CORS policy', status: 500 }, + }); +} + +router.get( + '/config', + (_req, res) => { + const policy = getCorsPolicy(); + res.json({ + allowCredentials: policy.credentials, + wildcard: policy.wildcard, + origins: policy.list(), + version: policy.version, + metrics: getCorsMetrics(), + }); + } +); + +router.put( + '/config', + (req, res) => { + try { + const body = configSchema.parse(req.body); + const policy = getCorsPolicy(); + policy.set(body.allowedOrigins); + if (body.allowCredentials !== undefined) { + policy.setCredentials(body.allowCredentials); + } + res.json({ + message: 'CORS policy updated', + origins: policy.list(), + wildcard: policy.wildcard, + version: policy.version, + allowCredentials: policy.credentials, + }); + } catch (err) { + policyError(res, err); + } + } +); + +router.get( + '/origins', + (_req, res) => { + res.json({ origins: getCorsPolicy().list() }); + } +); + +router.post( + '/origins', + (req, res) => { + try { + const { origin } = upsertSchema.parse(req.body); + const size = getCorsPolicy().add(origin); + res.status(201).json({ message: 'Origin added', origin, size }); + } catch (err) { + policyError(res, err); + } + } +); + +router.delete( + '/origins', + (req, res) => { + const parsed = z.string().trim().min(1).safeParse(req.query.origin); + if (!parsed.success) { + res.status(400).json({ + error: { code: 'VALIDATION_FAILED', message: 'origin query param is required', status: 400 }, + }); + return; + } + const removed = getCorsPolicy().remove(parsed.data); + res.json({ message: removed ? 'Origin removed' : 'Origin not found', origin: parsed.data, removed }); + } +); + +router.post( + '/refresh', + (req, res) => { + getCorsPolicy() + .refresh() + .then((origins) => { + res.json({ message: 'CORS policy refreshed', origins }); + }) + .catch((err) => { + policyError(res, err); + }); + } +); + +export const corsRouter = router; \ No newline at end of file diff --git a/backend/src/services/__tests__/circuitBreaker.test.ts b/backend/src/services/__tests__/circuitBreaker.test.ts new file mode 100644 index 00000000..d93be1a3 --- /dev/null +++ b/backend/src/services/__tests__/circuitBreaker.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, vi } from 'vitest'; +import { CircuitBreaker, CircuitBreakerError } from '../circuitBreaker.js'; +import { CircuitBreakerRegistry } from '../circuitBreakerRegistry.js'; + +function clock() { + let t = 0; + const now = () => t; + const advance = (ms: number) => { + t += ms; + }; + return { now, advance }; +} + +const baseConfig = { + slidingWindowSize: 20, + minimumCallsToOpen: 3, + failureRateThreshold: 50, + failureThreshold: 3, + successThreshold: 2, + waitDurationInOpenState: 1000, + permittedNumberOfCallsInHalfOpenState: 2, + requestTimeoutMs: 0, + failClosed: false, +}; + +describe('CircuitBreaker', () => { + it('starts closed and permits calls', () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + expect(cb.snapshot().state).toBe('closed'); + expect(cb.isCallPermitted()).toBe(true); + }); + + it('rejects a non-empty name requirement', () => { + expect(() => new CircuitBreaker('', baseConfig)).toThrow(/non-empty name/); + }); + + it('validates slidingWindowSize and failureRateThreshold', () => { + expect(() => new CircuitBreaker('x', { ...baseConfig, slidingWindowSize: 0 })).toThrow(/slidingWindowSize/); + expect(() => new CircuitBreaker('x', { ...baseConfig, failureRateThreshold: 101 })).toThrow(/failureRateThreshold/); + }); + + it('opens after the consecutive failure threshold', () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', { ...baseConfig, failureThreshold: 3 }, now); + cb.recordFailure(new Error('a')); + cb.recordFailure(new Error('b')); + expect(cb.snapshot().state).toBe('closed'); + cb.recordFailure(new Error('c')); + expect(cb.snapshot().state).toBe('open'); + }); + + it('opens by failure rate once the window is saturated', () => { + const { now } = clock(); + const cb = new CircuitBreaker( + 'svc', + { ...baseConfig, failureThreshold: 100, minimumCallsToOpen: 4, failureRateThreshold: 50 }, + now, + ); + // mix: 2 success, 2 failure => 50% with 4 calls >= minimum + cb.recordSuccess(); + cb.recordSuccess(); + cb.recordFailure(new Error('f1')); + cb.recordFailure(new Error('f2')); + expect(cb.snapshot().state).toBe('open'); + }); + + it('does not open by rate before minimum calls', () => { + const { now } = clock(); + const cb = new CircuitBreaker( + 'svc', + { ...baseConfig, failureThreshold: 100, minimumCallsToOpen: 4, failureRateThreshold: 50 }, + now, + ); + cb.recordFailure(new Error('f1')); + cb.recordFailure(new Error('f2')); + expect(cb.snapshot().state).toBe('closed'); + }); + + it('transitions open -> half_open after the wait and rejects while open', () => { + const { now, advance } = clock(); + const cb = new CircuitBreaker('svc', { ...baseConfig, waitDurationInOpenState: 1000 }, now); + cb.recordFailure(new Error('1')); + cb.recordFailure(new Error('2')); + cb.recordFailure(new Error('3')); + expect(cb.snapshot().state).toBe('open'); + expect(cb.isCallPermitted()).toBe(false); + expect(cb.snapshot().metrics.rejectedCalls).toBe(1); + + advance(1001); + expect(cb.isCallPermitted()).toBe(true); + expect(cb.snapshot().state).toBe('half_open'); + }); + + it('re-opens on a half_open failure', () => { + const { now, advance } = clock(); + const cb = new CircuitBreaker('svc', { ...baseConfig, waitDurationInOpenState: 1000 }, now); + for (let i = 0; i < 3; i++) cb.recordFailure(new Error(`f${i}`)); + advance(1001); + expect(cb.isCallPermitted()).toBe(true); + expect(cb.snapshot().state).toBe('half_open'); + cb.recordFailure(new Error('still failing')); + expect(cb.snapshot().state).toBe('open'); + }); + + it('re-closes after the success threshold in half_open', () => { + const { now, advance } = clock(); + const cb = new CircuitBreaker('svc', { ...baseConfig, waitDurationInOpenState: 1000 }, now); + for (let i = 0; i < 3; i++) cb.recordFailure(new Error(`f${i}`)); + advance(1001); + expect(cb.isCallPermitted()).toBe(true); + cb.recordSuccess(); + cb.recordSuccess(); + expect(cb.snapshot().state).toBe('closed'); + }); + + it('limits half-open calls to the permitted count', () => { + const { now, advance } = clock(); + const cb = new CircuitBreaker( + 'svc', + { ...baseConfig, waitDurationInOpenState: 1000, permittedNumberOfCallsInHalfOpenState: 2 }, + now, + ); + for (let i = 0; i < 3; i++) cb.recordFailure(new Error(`f${i}`)); + advance(1001); + expect(cb.isCallPermitted()).toBe(true); + expect(cb.isCallPermitted()).toBe(true); + expect(cb.isCallPermitted()).toBe(false); + }); + + it('protect resolves and records success', async () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + await expect(cb.protect(async () => 42)).resolves.toBe(42); + expect(cb.snapshot().metrics.successfulCalls).toBe(1); + expect(cb.snapshot().metrics.calls).toBe(1); + }); + + it('protect rejects and records failure, then throws', async () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + await expect(cb.protect(async () => { + throw new Error('boom'); + })).rejects.toThrow('boom'); + expect(cb.snapshot().metrics.failedCalls).toBe(1); + }); + + it('throws CircuitBreakerError while open and uses the fallback when provided', async () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', { ...baseConfig, waitDurationInOpenState: 1000 }, now); + for (let i = 0; i < 3; i++) { + await cb.protect(async () => { + throw new Error(`f${i}`); + }).catch(() => undefined); + } + expect(cb.snapshot().state).toBe('open'); + + await expect(cb.protect(async () => 'x')).rejects.toBeInstanceOf(CircuitBreakerError); + + const fallback = vi.fn(async () => 'fallback-value'); + await expect(cb.protect(async () => 'x', fallback)).resolves.toBe('fallback-value'); + expect(fallback).toHaveBeenCalled(); + }); + + it('invokes the fallback on call failure', async () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + const fallback = vi.fn(async () => 'fb'); + await expect( + cb.protect(async () => { + throw new Error('boom'); + }, fallback), + ).resolves.toBe('fb'); + expect(fallback).toHaveBeenCalled(); + }); + + it('times out and records a timeout, using the fallback', async () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', { ...baseConfig, requestTimeoutMs: 20 }, now); + const fallback = vi.fn(async () => 'fb'); + await expect( + cb.protect( + () => new Promise((resolve) => setTimeout(() => resolve('late'), 50)), + fallback, + ), + ).resolves.toBe('fb'); + expect(cb.snapshot().metrics.timeoutCalls).toBe(1); + }); + + it('propagates a CircuitBreakerError thrown by the guarded function without recording it', async () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + await expect( + cb.protect(async () => { + throw new CircuitBreakerError('other', 'nested'); + }), + ).rejects.toBeInstanceOf(CircuitBreakerError); + expect(cb.snapshot().metrics.failedCalls).toBe(0); + }); + + it('honours ignoreFailures predicate', async () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', { ...baseConfig, ignoreFailures: () => true }, now); + for (let i = 0; i < 5; i++) await cb.protect(async () => { throw new Error('ignored'); }).catch(() => undefined); + expect(cb.snapshot().state).toBe('closed'); + }); + + it('honours the recordFailure predicate to not drive the breaker', async () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', { ...baseConfig, recordFailure: () => false }, now); + for (let i = 0; i < 10; i++) await cb.protect(async () => { throw new Error('x'); }).catch(() => undefined); + expect(cb.snapshot().state).toBe('closed'); + expect(cb.snapshot().metrics.failedCalls).toBe(10); + }); + + it('reset clears state and metrics and closes the circuit', () => { + const { now, advance } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + for (let i = 0; i < 3; i++) cb.recordFailure(new Error(`f${i}`)); + advance(500); + cb.reset(); + const s = cb.snapshot(); + expect(s.state).toBe('closed'); + expect(s.metrics.calls).toBe(0); + expect(s.metrics.failedCalls).toBe(0); + }); + + it('resetMetrics clears history but keeps the current state', () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + cb.recordFailure(new Error('a')); + cb.recordFailure(new Error('b')); + cb.resetMetrics(); + expect(cb.snapshot().state).toBe('closed'); + expect(cb.snapshot().consecutiveFailures).toBe(0); + expect(cb.snapshot().metrics.failureRate).toBe(0); + }); + + it('notifies state-change observers and tolerates listener errors', () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + const events: string[] = []; + const unsub = cb.onStateChange((from, to) => { + events.push(`${from}->${to}`); + }); + cb.onStateChange(() => { + throw new Error('listener boom'); + }); + for (let i = 0; i < 3; i++) cb.recordFailure(new Error(`f${i}`)); + expect(events).toContain('closed->open'); + expect(cb.snapshot().state).toBe('open'); + unsub(); + }); + + it('records metrics totals across outcomes', () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + cb.recordSuccess(); + cb.recordSuccess(); + cb.recordFailure(new Error('f')); + const s = cb.snapshot(); + expect(s.metrics.calls).toBe(3); + expect(s.metrics.successfulCalls).toBe(2); + expect(s.metrics.failedCalls).toBe(1); + expect(s.metrics.failureRate).toBe(33); + }); + + it('failClosed rejects when closed but with no history is not applicable (uses guard)', () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', { ...baseConfig, failClosed: true, failureThreshold: 1 }, now); + // failClosed is a policy lever; closed still permits by design. Ensure it exists in config. + expect(cb.config.failClosed).toBe(true); + expect(cb.isCallPermitted()).toBe(true); + }); + + it('runs synchronous functions via protect', async () => { + const { now } = clock(); + const cb = new CircuitBreaker('svc', baseConfig, now); + await expect(cb.protect(() => 'sync')).resolves.toBe('sync'); + }); +}); + +describe('CircuitBreakerRegistry', () => { + it('creates and reuses instances by name', () => { + const reg = new CircuitBreakerRegistry(clock().now); + const a = reg.get('a'); + expect(reg.get('a')).toBe(a); + expect(reg.has('a')).toBe(true); + expect(reg.getIfPresent('missing')).toBeUndefined(); + }); + + it('tracks names and snapshots', () => { + const reg = new CircuitBreakerRegistry(clock().now); + reg.get('x'); + reg.get('y'); + expect(reg.names().sort()).toEqual(['x', 'y']); + expect(reg.snapshots()).toHaveLength(2); + }); + + it('reset returns false for unknown names and works for known', () => { + const reg = new CircuitBreakerRegistry(clock().now); + expect(reg.reset('nope')).toBe(false); + reg.get('k'); + expect(reg.reset('k')).toBe(true); + }); + + it('resetAll resets every circuit', () => { + const { now } = clock(); + const reg = new CircuitBreakerRegistry(now); + const b = reg.get('k', { ...baseConfig, failureThreshold: 3 }); + b.recordFailure(new Error('f')); + b.recordFailure(new Error('f')); + b.recordFailure(new Error('f')); + expect(b.snapshot().state).toBe('open'); + reg.resetAll(); + expect(b.snapshot().state).toBe('closed'); + }); + + it('delete and clear remove entries', () => { + const reg = new CircuitBreakerRegistry(clock().now); + reg.get('a'); + reg.get('b'); + expect(reg.delete('a')).toBe(true); + expect(reg.has('a')).toBe(false); + reg.clear(); + expect(reg.names()).toHaveLength(0); + }); +}); diff --git a/backend/src/services/__tests__/cors.test.ts b/backend/src/services/__tests__/cors.test.ts new file mode 100644 index 00000000..41a0318f --- /dev/null +++ b/backend/src/services/__tests__/cors.test.ts @@ -0,0 +1,307 @@ +/** + * cors.test.ts — Unit tests for the dynamic CORS origin whitelist service. + * + * Covers validation, pattern matching (exact / subdomain wildcard / + * scheme-relative / open), the runtime-mutable policy, async loader refresh, + * and shared metrics. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CorsPolicyError, + CORSOriginPolicy, + addAllowedOrigin, + getAllowedOrigins, + getCorsMetrics, + getCorsPolicy, + initCorsPolicy, + isOriginAllowed, + isValidOrigin, + isValidPattern, + originMatches, + recordPreflight, + refreshAllowedOrigins, + removeAllowedOrigin, + resetCorsMetrics, + setAllowedOrigins, +} from '../cors.js'; + +describe('isValidOrigin', () => { + it('accepts well-formed origins', () => { + expect(isValidOrigin('https://app.example.com')).toBe(true); + expect(isValidOrigin('https://app.example.com:8443')).toBe(true); + expect(isValidOrigin('http://localhost:5173')).toBe(true); + expect(isValidOrigin('HTTPS://APP.EXAMPLE.COM')).toBe(true); + expect(isValidOrigin('ws://socket.example.com')).toBe(true); + }); + + it('rejects origins with paths, queries, or fragments', () => { + expect(isValidOrigin('https://app.example.com/')).toBe(false); + expect(isValidOrigin('https://app.example.com/path')).toBe(false); + expect(isValidOrigin('https://app.example.com?q=1')).toBe(false); + expect(isValidOrigin('https://app.example.com#frag')).toBe(false); + }); + + it('rejects missing schemes, whitespace, and control characters', () => { + expect(isValidOrigin('app.example.com')).toBe(false); + expect(isValidOrigin('https://')).toBe(false); + expect(isValidOrigin('https://:80')).toBe(false); + expect(isValidOrigin('https://app example.com')).toBe(false); + expect(isValidOrigin('https://app.example.com\n')).toBe(false); + expect(isValidOrigin('https:/\u0000app.example.com')).toBe(false); + }); + + it('rejects null, empty, and oversized origins', () => { + expect(isValidOrigin('null')).toBe(false); + expect(isValidOrigin('')).toBe(false); + expect(isValidOrigin('https://' + 'a'.repeat(300) + '.com')).toBe(false); + }); +}); + +describe('isValidPattern', () => { + it('accepts exact origins and wildcard patterns', () => { + expect(isValidPattern('https://app.example.com')).toBe(true); + expect(isValidPattern('https://*.example.com')).toBe(true); + expect(isValidPattern('https://*.app.example.com')).toBe(true); + expect(isValidPattern('*.example.com')).toBe(true); + expect(isValidPattern('example.com')).toBe(true); + expect(isValidPattern('*')).toBe(true); + }); + + it('rejects junk entries', () => { + expect(isValidPattern('')).toBe(false); + expect(isValidPattern('app.example.com/path')).toBe(false); + expect(isValidPattern('https://*.example.com/path')).toBe(false); + expect(isValidPattern('a b')).toBe(false); + expect(isValidPattern('sub.*.example.com')).toBe(false); + expect(isValidPattern('example.com:8080')).toBe(false); + expect(isValidPattern(42 as unknown as string)).toBe(false); + expect(isValidPattern('https://' + 'a'.repeat(300) + '.com')).toBe(false); + }); +}); + +describe('originMatches', () => { + it('matches exact scheme + host, ignoring the port', () => { + expect(originMatches('https://app.example.com', 'https://app.example.com')).toBe(true); + expect(originMatches('https://app.example.com', 'https://app.example.com:8443')).toBe(true); + expect(originMatches('https://app.example.com', 'http://app.example.com')).toBe(false); + expect(originMatches('https://app.example.com', 'https://other.example.com')).toBe(false); + }); + + it('compares scheme and host case-insensitively', () => { + expect(originMatches('https://APP.EXAMPLE.COM', 'https://app.example.com')).toBe(true); + expect(originMatches('https://app.example.com', 'HTTPS://App.Example.Com')).toBe(true); + }); + + it('matches subdomain wildcards including the apex', () => { + expect(originMatches('https://*.example.com', 'https://example.com')).toBe(true); + expect(originMatches('https://*.example.com', 'https://app.example.com')).toBe(true); + expect(originMatches('https://*.example.com', 'https://deep.app.example.com')).toBe(true); + expect(originMatches('https://*.example.com', 'https://example.org')).toBe(false); + expect(originMatches('https://*.example.com', 'https://notexample.com')).toBe(false); + expect(originMatches('https://*.example.com', 'https://app.example.org')).toBe(false); + }); + + it('matches scheme-relative patterns against any scheme', () => { + expect(originMatches('example.com', 'https://example.com')).toBe(true); + expect(originMatches('example.com', 'http://example.com')).toBe(true); + expect(originMatches('*.example.com', 'https://app.example.com')).toBe(true); + expect(originMatches('*.example.com', 'wss://app.example.com')).toBe(true); + expect(originMatches('example.com', 'https://other.com')).toBe(false); + }); + + it('honours the open wildcard and rejects malformed origins', () => { + expect(originMatches('*', 'https://anything.example.com')).toBe(true); + expect(originMatches('https://app.example.com', 'https://app.example.com/path')).toBe(false); + expect(originMatches('https://app.example.com', '')).toBe(false); + }); +}); + +describe('CORSOriginPolicy', () => { + beforeEach(() => { + resetCorsMetrics(); + }); + + it('seeds from allowedOrigins and lists them sorted', () => { + const policy = new CORSOriginPolicy({ + allowedOrigins: ['https://b.example.com', 'https://a.example.com'], + }); + expect(policy.list()).toEqual(['https://a.example.com', 'https://b.example.com']); + expect(policy.size).toBe(2); + }); + + it('adds and removes entries dynamically', () => { + const policy = new CORSOriginPolicy(); + expect(policy.add('https://app.example.com')).toBe(1); + expect(policy.list()).toEqual(['https://app.example.com']); + expect(policy.add('https://admin.example.com')).toBe(2); + + expect(policy.remove('https://app.example.com')).toBe(true); + expect(policy.size).toBe(1); + expect(policy.remove('https://missing.example.com')).toBe(false); + }); + + it('normalises casing on add and keeps removal consistent', () => { + const policy = new CORSOriginPolicy(); + expect(policy.add('HTTPS://App.Example.COM')).toBe(1); + expect(policy.list()).toEqual(['https://app.example.com']); + expect(policy.remove('https://App.Example.com')).toBe(true); + expect(policy.size).toBe(0); + }); + + it('throws CorsPolicyError on invalid entries', () => { + const policy = new CORSOriginPolicy(); + expect(() => policy.add('has space')).toThrow(CorsPolicyError); + expect(() => policy.add('https://x.example.com/path')).toThrow(CorsPolicyError); + expect(() => policy.add('https://x.example.com?q=1')).toThrow(CorsPolicyError); + expect(() => policy.add('https://foo*.example.com')).toThrow(CorsPolicyError); + expect(() => policy.set(['https://ok.example.com', 'https://bad example.com'])).toThrow(CorsPolicyError); + }); + + it('keeps the previous allowlist when set() is given a bad entry (atomic)', () => { + const policy = new CORSOriginPolicy({ allowedOrigins: ['https://ok.example.com'] }); + expect(() => policy.set(['https://bad example.com'])).toThrow(CorsPolicyError); + expect(policy.list()).toEqual(['https://ok.example.com']); + expect(policy.size).toBe(1); + }); + + it('replaces the whole allowlist on set()', () => { + const policy = new CORSOriginPolicy({ allowedOrigins: ['https://a.example.com'] }); + policy.set(['https://b.example.com', 'https://c.example.com']); + expect(policy.list()).toEqual(['https://b.example.com', 'https://c.example.com']); + expect(policy.version).toBe(2); + }); + + it('tracks the wildcard flag with add/remove of "*"', () => { + const policy = new CORSOriginPolicy(); + expect(policy.wildcard).toBe(false); + policy.add('*'); + expect(policy.wildcard).toBe(true); + policy.remove('*'); + expect(policy.wildcard).toBe(false); + }); + + it('isAllowed concedes members only (and counts metrics)', () => { + const policy = new CORSOriginPolicy({ + allowedOrigins: ['https://app.example.com', 'https://*.static.example.com'], + }); + + expect(policy.isAllowed('https://app.example.com')).toBe(true); + expect(policy.isAllowed('https://cdn.static.example.com')).toBe(true); + expect(policy.isAllowed('https://static.example.com')).toBe(true); + expect(policy.isAllowed('https://hacker.example.org')).toBe(false); + + const m = getCorsMetrics(); + expect(m.allowedRequests).toBe(3); + expect(m.deniedRequests).toBe(1); + }); + + it('isAllowed denies null, empty, and malformed origins', () => { + const policy = new CORSOriginPolicy({ allowedOrigins: ['*'] }); + expect(policy.isAllowed(null)).toBe(false); + expect(policy.isAllowed(undefined)).toBe(false); + expect(policy.isAllowed('')).toBe(false); + expect(policy.isAllowed('null')).toBe(false); + expect(policy.isAllowed('not-a-origin')).toBe(false); + }); + + it('denies everything when the allowlist is empty', () => { + const policy = new CORSOriginPolicy(); + expect(policy.isAllowed('https://app.example.com')).toBe(false); + }); + + it('allows any valid origin in open mode', () => { + const policy = new CORSOriginPolicy({ allowedOrigins: ['*'] }); + expect(policy.isAllowed('https://anything.example.com')).toBe(true); + expect(policy.isAllowed('http://localhost:5173')).toBe(true); + }); + + it('refresh() replaces the list from the loader', async () => { + const loader = vi.fn().mockResolvedValue(['https://new.example.com', 'https://*.new.example.com']); + const policy = new CORSOriginPolicy({ + allowedOrigins: ['https://old.example.com'], + loader, + }); + const origins = await policy.refresh(); + expect(origins).toEqual(['https://*.new.example.com', 'https://new.example.com']); + expect(policy.isAllowed('https://old.example.com')).toBe(false); + expect(policy.isAllowed('https://app.new.example.com')).toBe(true); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('refresh() fails safe: rejects keep the old list', async () => { + const loader = vi.fn().mockRejectedValue(new Error('source down')); + const policy = new CORSOriginPolicy({ + allowedOrigins: ['https://old.example.com'], + loader, + }); + await expect(policy.refresh()).rejects.toThrow(); + expect(policy.list()).toEqual(['https://old.example.com']); + }); + + it('refresh() rejects loader results with invalid entries', async () => { + const loader = vi.fn().mockResolvedValue(['https://ok.example.com', 'bad/entry']); + const policy = new CORSOriginPolicy({ + allowedOrigins: ['https://old.example.com'], + loader, + }); + await expect(policy.refresh()).rejects.toThrow(CorsPolicyError); + expect(policy.list()).toEqual(['https://old.example.com']); + }); + + it('refresh() throws when no loader is configured', async () => { + const policy = new CORSOriginPolicy({ allowedOrigins: ['https://a.example.com'] }); + await expect(policy.refresh()).rejects.toThrow(); + }); +}); + +describe('shared policy and convenience wrappers', () => { + afterEach(() => { + resetCorsMetrics(); + initCorsPolicy({ allowedOrigins: [] }); + }); + + it('shares a single default policy across the module', () => { + expect(getAllowedOrigins()).toEqual([]); + addAllowedOrigin('https://app.example.com'); + addAllowedOrigin('https://*.example.com'); + expect(getAllowedOrigins()).toEqual(['https://*.example.com', 'https://app.example.com']); + expect(getCorsPolicy().size).toBe(2); + + expect(removeAllowedOrigin('https://app.example.com')).toBe(true); + expect(isOriginAllowed('https://x.example.com')).toBe(true); + expect(isOriginAllowed('https://hacker.org')).toBe(false); + }); + + it('setAllowedOrigins replaces the shared list and is atomic', () => { + setAllowedOrigins(['https://a.example.com']); + expect(getAllowedOrigins()).toEqual(['https://a.example.com']); + expect(() => setAllowedOrigins(['bad entry'])).toThrow(CorsPolicyError); + expect(getAllowedOrigins()).toEqual(['https://a.example.com']); + }); + + it('refreshAllowedOrigins uses the shared policy loader', async () => { + initCorsPolicy({ + allowedOrigins: ['https://old.example.com'], + loader: vi.fn().mockResolvedValue(['https://new.example.com']), + }); + await expect(refreshAllowedOrigins()).resolves.toEqual(['https://new.example.com']); + }); + + it('recordPreflight and metrics reset', () => { + recordPreflight(true); + recordPreflight(true); + recordPreflight(false); + const m = getCorsMetrics(); + expect(m.preflights).toBe(3); + expect(m.preflightDenied).toBe(1); + + resetCorsMetrics(); + expect(getCorsMetrics()).toEqual({ + allowedRequests: 0, + deniedRequests: 0, + preflights: 0, + preflightDenied: 0, + }); + }); +}); \ No newline at end of file diff --git a/backend/src/services/__tests__/webhookKeys.test.ts b/backend/src/services/__tests__/webhookKeys.test.ts new file mode 100644 index 00000000..4c91cfdf --- /dev/null +++ b/backend/src/services/__tests__/webhookKeys.test.ts @@ -0,0 +1,455 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + WebhookKeyRegistry, + WebhookKeyError, + generateWebhookKeySecret, + buildWebhookDigest, + constantTimeEqualHex, + parseWebhookSignature, + normalizeWebhookTimestamp, + getWebhookKeyRegistry, + initWebhookKeyRegistry, + resetWebhookKeyRegistry, + DEFAULT_TOLERANCE_SECONDS, + DEFAULT_OVERLAP_SECONDS, + MIN_WEBHOOK_KEY_SECRET_LENGTH, +} from '../webhookKeys'; + +const PAYLOAD = JSON.stringify({ event: 'payment.created', data: { amount: 100, currency: 'USD' } }); + +function makeRegistry(overrides: { overlapSeconds?: number; toleranceSeconds?: number; retentionSeconds?: number } = {}) { + let clock = 1_700_000_000_000; + const registry = new WebhookKeyRegistry({ + now: () => clock, + overlapSeconds: overrides.overlapSeconds ?? DEFAULT_OVERLAP_SECONDS, + toleranceSeconds: overrides.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS, + retentionSeconds: overrides.retentionSeconds, + }); + return { + registry, + advance(ms: number) { + clock += ms; + return clock; + }, + clock() { + return clock; + }, + }; +} + +describe('webhookKeys service', () => { + describe('generateWebhookKeySecret', () => { + it('generates unique secrets of sufficient length', () => { + const a = generateWebhookKeySecret(); + const b = generateWebhookKeySecret(); + expect(a).not.toBe(b); + expect(a.length).toBeGreaterThanOrEqual(32); + }); + }); + + describe('digest helpers', () => { + it('builds deterministic HMAC digests from timestamp + body', () => { + const secret = 'x'.repeat(32); + const a = buildWebhookDigest(PAYLOAD, secret, 1700000000); + const b = buildWebhookDigest(PAYLOAD, secret, 1700000000); + expect(a).toBe(b); + expect(a).toMatch(/^[a-f0-9]{64}$/); + expect(buildWebhookDigest('other', secret, 1700000000)).not.toBe(a); + }); + + it('builds digests from Buffer bodies identically to strings', () => { + const secret = 'y'.repeat(32); + expect(buildWebhookDigest(Buffer.from(PAYLOAD), secret, 5)).toBe(buildWebhookDigest(PAYLOAD, secret, 5)); + }); + + it('compares hex constant-time only for equal-length same digests', () => { + const d = 'ab'.repeat(32); + expect(constantTimeEqualHex(d, d)).toBe(true); + expect(constantTimeEqualHex(d, 'ab'.repeat(31) + 'cd')).toBe(false); + expect(constantTimeEqualHex(d, 'zz')).toBe(false); + }); + }); + + describe('parseWebhookSignature', () => { + it('parses bare hex digests', () => { + const digest = 'ab'.repeat(32); + expect(parseWebhookSignature(digest)).toEqual({ digest }); + }); + + it('parses versioned and prefixed forms', () => { + const digest = 'cd'.repeat(32); + expect(parseWebhookSignature(`v1=${digest}`)).toEqual({ digest }); + expect(parseWebhookSignature(`sha256=${digest}`)).toEqual({ digest }); + expect(parseWebhookSignature(`sig-sha256=${digest}`)).toEqual({ digest }); + expect(parseWebhookSignature(`sha256-${digest}`)).toEqual({ digest }); + }); + + it('rejects malformed signatures', () => { + expect(parseWebhookSignature('')).toBeNull(); + expect(parseWebhookSignature('v1=nothex')).toBeNull(); + expect(parseWebhookSignature('e'.repeat(63))).toBeNull(); + expect(parseWebhookSignature('v1=data.validhexnot64')).toBeNull(); + }); + + it('trims whitespace and normalizes hex case', () => { + expect(parseWebhookSignature(` v1=${'AB'.repeat(32)} `)).toEqual({ digest: 'ab'.repeat(32) }); + }); + + it('extracts embedded keyId from dotted signatures', () => { + const digest = 'ef'.repeat(32); + expect(parseWebhookSignature(`v1=wvk_abc.${digest}`)).toEqual({ keyId: 'wvk_abc', digest }); + }); + + it('treats 64-hex bare tokens without prefix as digests not keyIds', () => { + const digest = '12'.repeat(32); + expect(parseWebhookSignature(digest)).toEqual({ digest }); + expect(parseWebhookSignature(digest).keyId).toBeUndefined(); + }); + }); + + describe('normalizeWebhookTimestamp', () => { + it('handles seconds, milliseconds, ISO strings, and strings', () => { + expect(normalizeWebhookTimestamp(1700000000)).toBe(1_700_000_000_000); + expect(normalizeWebhookTimestamp('1700000000')).toBe(1_700_000_000_000); + expect(normalizeWebhookTimestamp(1700000000000)).toBe(1_700_000_000_000); + expect(normalizeWebhookTimestamp('2023-11-14T22:13:20.000Z')).toBe(1_700_000_000_000); + }); + + it('rejects unparseable timestamps', () => { + expect(normalizeWebhookTimestamp('')).toBeNull(); + expect(normalizeWebhookTimestamp('nope')).toBeNull(); + expect(normalizeWebhookTimestamp(' ')).toBeNull(); + expect(normalizeWebhookTimestamp(Number.NaN)).toBeNull(); + }); + }); + + describe('register', () => { + it('registers keys with generated id + secret', () => { + const { registry } = makeRegistry(); + const key = registry.register({ provider: 'custom' }); + expect(key.keyId).toMatch(/^wvk_custom_/); + expect(key.secret).toHaveLength(43); + expect(key.status).toBe('active'); + expect(key.algorithm).toBe('sha256'); + expect(registry.size).toBe(1); + }); + + it('honors provided secret, keyId, label, and expiry', () => { + const { registry } = makeRegistry(); + const secret = 'k'.repeat(40); + const key = registry.register({ provider: 'custom', secret, keyId: 'wvk_known', label: 'staging', expiresAt: 12345 }); + expect(key.keyId).toBe('wvk_known'); + expect(key.secret).toBe(secret); + expect(key.label).toBe('staging'); + expect(key.expiresAt).toBe(12345); + }); + + it('rejects short secrets', () => { + const { registry } = makeRegistry(); + expect(() => registry.register({ secret: 'short' })).toThrow(WebhookKeyError); + expect(() => registry.register({ secret: 'short' })).toThrow(/at least/); + expect(() => registry.register({ secret: 'a'.repeat(MIN_WEBHOOK_KEY_SECRET_LENGTH - 1) })).toThrow(); + }); + + it('rejects duplicate keyIds', () => { + const { registry } = makeRegistry(); + registry.register({ keyId: 'wvk_dup', secret: 's'.repeat(32) }); + expect(() => registry.register({ keyId: 'wvk_dup', secret: 's'.repeat(32) })).toThrow(WebhookKeyError); + expect(() => registry.register({ keyId: 'wvk_dup' })).toThrow(/already exists/); + }); + }); + + describe('sign/verify roundtrip', () => { + it('signs and verifies with the active key for a provider', () => { + const { registry, clock } = makeRegistry(); + registry.register({ provider: 'custom', secret: 'secret_custom_00112233445566778899' }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + expect(signed.version).toBe('v1'); + expect(signed.timestamp).toBe(String(Math.floor(clock() / 1000))); + expect(signed.signature).toMatch(/^v1=[a-f0-9]{64}$/); + + const result = registry.verify({ signature: signed.signature, timestamp: signed.timestamp, body: PAYLOAD, provider: 'custom' }); + expect(result.isValid).toBe(true); + expect(result.keyId).toBe(signed.keyId); + expect(registry.metrics().verified).toBe(1); + }); + + it('verifies bare-hex and legacy sha256= forms', () => { + const { registry } = makeRegistry(); + const secret = 'z'.repeat(40); + registry.register({ provider: 'custom', secret }); + const timestamp = 1700000000; + const digest = buildWebhookDigest(PAYLOAD, secret, timestamp); + for (const signature of [digest, `sha256=${digest}`, `v1=${digest}`]) { + expect(registry.verify({ signature, timestamp, body: PAYLOAD, provider: 'custom' }).isValid).toBe(true); + } + }); + + it('verifies against a specific keyId hint', () => { + const { registry } = makeRegistry(); + registry.register({ provider: 'custom', secret: 'a'.repeat(32), keyId: 'wvk_hint' }); + const signed = registry.sign({ keyId: 'wvk_hint', body: PAYLOAD }); + const result = registry.verify({ signature: signed.signature, timestamp: signed.timestamp, body: PAYLOAD, keyId: 'wvk_hint' }); + expect(result.isValid).toBe(true); + }); + + it('rejects tampered bodies and wrong signatures', () => { + const { registry, clock } = makeRegistry(); + registry.register({ provider: 'custom', secret: 'b'.repeat(32) }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + expect(registry.verify({ signature: signed.signature, timestamp: signed.timestamp, body: PAYLOAD + 'x', provider: 'custom' }).isValid).toBe(false); + const forged = registry.verify({ signature: 'v1=' + '0'.repeat(64), timestamp: signed.timestamp, body: PAYLOAD, provider: 'custom' }); + expect(forged.isValid).toBe(false); + expect(forged.reason).toBe('signature_mismatch'); + expect(registry.metrics().rejected).toBe(2); + expect(clock()).toBe(registry.nowMs); + }); + + it('accepts Buffer bodies', () => { + const { registry } = makeRegistry(); + registry.register({ provider: 'custom', secret: 'c'.repeat(32) }); + const signed = registry.sign({ provider: 'custom', body: Buffer.from(PAYLOAD) }); + const result = registry.verify({ signature: signed.signature, timestamp: signed.timestamp, body: Buffer.from(PAYLOAD), provider: 'custom' }); + expect(result.isValid).toBe(true); + }); + + it('rejects without signature, timestamp, or on invalid format', () => { + const { registry, clock } = makeRegistry(); + registry.register({ provider: 'custom', secret: 'd'.repeat(32) }); + expect(registry.verify({ signature: '', timestamp: '1700000000', body: PAYLOAD }).reason).toBe('missing_signature'); + expect(registry.verify({ signature: 'v1=zzzz', timestamp: '1700000000', body: PAYLOAD }).reason).toBe('invalid_signature_format'); + expect(registry.verify({ signature: 'v1=' + 'a'.repeat(64), timestamp: '', body: PAYLOAD }).reason).toBe('missing_timestamp'); + expect(registry.verify({ signature: 'v1=' + 'a'.repeat(64), timestamp: 'nope', body: PAYLOAD }).reason).toBe('missing_timestamp'); + const reasons = registry.metrics().rejectionsByReason; + expect(reasons.missing_signature).toBe(1); + expect(reasons.invalid_signature_format).toBe(1); + expect(reasons.missing_timestamp).toBe(2); + expect(clock()).toBe(registry.nowMs); + }); + + it('rejects timestamps outside tolerance', () => { + const { registry, advance } = makeRegistry({ toleranceSeconds: 300 }); + registry.register({ provider: 'custom', secret: 'e'.repeat(32) }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + + const hop = (ms: number) => { + advance(ms); + return registry.verify({ signature: signed.signature, timestamp: signed.timestamp, body: PAYLOAD, provider: 'custom' }); + }; + const ok = hop(299_000); + expect(ok.isValid).toBe(true); + const stale = hop(2_000); + const result = registry.verify({ signature: signed.signature, timestamp: signed.timestamp, body: PAYLOAD, provider: 'custom' }); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('timestamp_out_of_tolerance'); + expect(stale.isValid).toBe(false); + }); + + it('rejects when the provider has no keys', () => { + const { registry } = makeRegistry(); + const result = registry.verify({ signature: 'v1=' + 'f'.repeat(64), timestamp: '1700000000', body: PAYLOAD, provider: 'github' }); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('no_keys'); + expect(result.error).toMatch(/github/); + }); + + it('scopes keys per provider', () => { + const { registry } = makeRegistry(); + registry.register({ provider: 'custom', secret: 'g'.repeat(32) }); + const signed = registry.sign({ provider: 'custom', body: PAYLOAD }); + const wrong = registry.verify({ signature: signed.signature, timestamp: signed.timestamp, body: PAYLOAD, provider: 'github' }); + expect(wrong.isValid).toBe(false); + expect(wrong.reason).toBe('no_keys'); + }); + }); + + describe('key rotation', () => { + it('rotates: retires old key, creates a new active key, keeps old verifiable during overlap', () => { + const { registry } = makeRegistry({ overlapSeconds: 3600 }); + const first = registry.register({ provider: 'custom', secret: 'first_first_first_first_first_01' }); + const signedOld = registry.sign({ keyId: first.keyId, body: PAYLOAD }); + + const { retired, active } = registry.rotate({ provider: 'custom', secret: 'second_second_second_second_second' }); + expect(retired?.keyId).toBe(first.keyId); + expect(retired?.status).toBe('retiring'); + expect(active.keyId).not.toBe(first.keyId); + expect(active.status).toBe('active'); + + const actives = registry.activeKeysFor('custom'); + expect(actives).toHaveLength(1); + expect(actives[0].keyId).toBe(active.keyId); + + const signedNew = registry.sign({ provider: 'custom', body: PAYLOAD }); + expect(signedNew.keyId).toBe(active.keyId); + + expect(registry.verify({ signature: signedOld.signature, timestamp: signedOld.timestamp, body: PAYLOAD, provider: 'custom' }).isValid).toBe(true); + expect(registry.verify({ signature: signedNew.signature, timestamp: signedNew.timestamp, body: PAYLOAD, provider: 'custom' }).isValid).toBe(true); + }); + + it('rejects the retired key once the overlap window elapses', () => { + const { registry, advance } = makeRegistry({ overlapSeconds: 3600, toleranceSeconds: 7200 }); + registry.register({ provider: 'custom', secret: 'h'.repeat(32) }); + const signedOld = registry.sign({ provider: 'custom', body: PAYLOAD }); + registry.rotate({ provider: 'custom', secret: 'i'.repeat(32) }); + + advance(3600 * 1000 + 1000); + const result = registry.verify({ signature: signedOld.signature, timestamp: signedOld.timestamp, body: PAYLOAD, provider: 'custom', keyId: signedOld.keyId }); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('key_expired'); + }); + + it('rotating with no prior key just creates the first active key', () => { + const { registry } = makeRegistry(); + const { retired, active } = registry.rotate({ provider: 'paypal', secret: 'j'.repeat(32) }); + expect(retired).toBeUndefined(); + expect(active.status).toBe('active'); + expect(registry.activeKeysFor('paypal')[0].keyId).toBe(active.keyId); + }); + + it('can rotate at a custom overlap', () => { + const { registry, advance } = makeRegistry({ overlapSeconds: DEFAULT_OVERLAP_SECONDS }); + registry.register({ provider: 'custom', secret: 'k'.repeat(32) }); + const oldSig = registry.sign({ provider: 'custom', body: PAYLOAD }); + registry.rotate({ provider: 'custom', secret: 'l'.repeat(32), overlapSeconds: 10 }); + const stillValid = registry.verify({ signature: oldSig.signature, timestamp: oldSig.timestamp, body: PAYLOAD, provider: 'custom', keyId: oldSig.keyId }); + expect(stillValid.isValid).toBe(true); + advance(11_000); + const expired = registry.verify({ signature: oldSig.signature, timestamp: oldSig.timestamp, body: PAYLOAD, provider: 'custom', keyId: oldSig.keyId }); + expect(expired.isValid).toBe(false); + }); + + it('sign rejects non-active and unknown keys', () => { + const { registry } = makeRegistry(); + const key = registry.register({ provider: 'custom', secret: 'm'.repeat(32) }); + registry.rotate({ provider: 'custom', secret: 'n'.repeat(32) }); + expect(() => registry.sign({ keyId: key.keyId, body: PAYLOAD })).toThrow(/not active/); + expect(() => registry.sign({ keyId: 'wvk_gone', body: PAYLOAD })).toThrow(/does not exist/); + expect(() => registry.sign({ provider: 'github', body: PAYLOAD })).toThrow(/No active key/); + expect(registry.metrics().signErrors).toBe(3); + }); + }); + + describe('revoke', () => { + it('revokes a key and causes its signatures to fail', () => { + const { registry } = makeRegistry(); + const key = registry.register({ provider: 'custom', secret: 'o'.repeat(32) }); + const signed = registry.sign({ keyId: key.keyId, body: PAYLOAD }); + registry.rotate({ provider: 'custom', secret: 'o2'.repeat(16) }); + expect(registry.revoke(key.keyId)).toBe(true); + + const result = registry.verify({ signature: signed.signature, timestamp: signed.timestamp, body: PAYLOAD, provider: 'custom', keyId: key.keyId }); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('key_revoked'); + expect(registry.getKey(key.keyId)?.status).toBe('revoked'); + expect(registry.metrics().revoked).toBe(1); + }); + + it('refuses to revoke the only active key', () => { + const { registry } = makeRegistry(); + const key = registry.register({ provider: 'custom', secret: 'p'.repeat(32) }); + expect(() => registry.revoke(key.keyId)).toThrow(WebhookKeyError); + expect(() => registry.revoke(key.keyId)).toThrow(/rotate first/); + }); + + it('returns false for unknown keys', () => { + const { registry } = makeRegistry(); + expect(registry.revoke('wvk_missing')).toBe(false); + }); + + it('does not verify with a revoked key when verified without hint', () => { + const { registry } = makeRegistry(); + const association = registry.register({ provider: 'custom', secret: 'q'.repeat(32) }); + const signedAssociation = registry.sign({ keyId: association.keyId, body: PAYLOAD }); + registry.rotate({ provider: 'custom', secret: 'r'.repeat(32) }); + expect(registry.revoke(association.keyId)).toBe(true); + const result = registry.verify({ signature: signedAssociation.signature, timestamp: signedAssociation.timestamp, body: PAYLOAD, provider: 'custom' }); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('key_revoked'); + }); + }); + + describe('expiry and retention', () => { + it('rejects active keys that have expired', () => { + const { registry, advance } = makeRegistry(); + const key = registry.register({ provider: 'custom', secret: 's'.repeat(32), expiresAt: 1_700_000_000_900 }); + const signed = registry.sign({ keyId: key.keyId, body: PAYLOAD }); + advance(2000); + const result = registry.verify({ signature: signed.signature, timestamp: signed.timestamp, body: PAYLOAD, provider: 'custom', keyId: key.keyId }); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('key_expired'); + }); + + it('purges old retired keys after the retention window', () => { + const { registry, advance } = makeRegistry({ retentionSeconds: 3600 }); + registry.register({ provider: 'custom', secret: 't'.repeat(32) }); + registry.rotate({ provider: 'custom', secret: 'u'.repeat(32) }); + const count = registry.size; + expect(count).toBe(2); + + expect(registry.purgeExpired()).toBe(0); + advance(3600 * 1000 + 1); + expect(registry.purgeExpired()).toBe(1); + expect(registry.size).toBe(1); + expect(registry.metrics().purged).toBe(1); + expect(registry.activeKeysFor('custom')).toHaveLength(1); + }); + + it('listKeys respects status and includeRevoked filters', () => { + const { registry } = makeRegistry(); + const a = registry.register({ provider: 'custom', secret: 'v'.repeat(32) }); + registry.rotate({ provider: 'custom', secret: 'w'.repeat(32) }); + expect(registry.listKeys({ provider: 'custom' }).length).toBe(2); + expect(registry.listKeys({ status: 'active' }).length).toBe(1); + expect(registry.listKeys({ includeRevoked: true }).length).toBe(2); + registry.revoke(a.keyId); + expect(registry.listKeys({ includeRevoked: true }).length).toBe(2); + expect(registry.listKeys().length).toBe(1); + }); + }); + + describe('metrics', () => { + it('tracks lifecycle and verification metrics', () => { + const { registry } = makeRegistry(); + registry.register({ provider: 'custom', secret: 'x'.repeat(32) }); + registry.rotate({ provider: 'custom', secret: 'x'.repeat(32) }); + expect(registry.metrics().registered).toBe(2); + expect(registry.metrics().rotated).toBe(1); + + registry.resetMetrics(); + expect(registry.metrics().registered).toBe(0); + expect(registry.metrics().rotated).toBe(0); + expect(registry.metrics().verifications).toBe(0); + }); + + it('getActiveKey returns newest active; hasKeysForProvider reflects retiring keys', () => { + const { registry } = makeRegistry(); + registry.register({ provider: 'custom', secret: 'y'.repeat(32) }); + const { active } = registry.rotate({ provider: 'custom', secret: 'y'.repeat(32) }); + expect(registry.getActiveKey('custom')?.keyId).toBe(active.keyId); + expect(registry.getActiveKey()?.provider).toBe('custom'); + expect(registry.hasKeysForProvider('custom')).toBe(true); + expect(registry.hasKeysForProvider('github')).toBe(false); + }); + }); + + describe('singleton', () => { + beforeEach(() => resetWebhookKeyRegistry()); + afterEach(() => resetWebhookKeyRegistry()); + + it('returns a stable shared instance', () => { + const a = getWebhookKeyRegistry(); + const b = getWebhookKeyRegistry(); + expect(a).toBe(b); + }); + + it('init replaces the instance and seeds configured keys', () => { + const registry = initWebhookKeyRegistry({ + keys: [{ provider: 'custom', secret: 'zz'.repeat(16) }], + toleranceSeconds: 60, + }); + expect(getWebhookKeyRegistry()).toBe(registry); + expect(registry.size).toBe(1); + expect(registry.toleranceSeconds).toBe(60); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/services/circuitBreaker.ts b/backend/src/services/circuitBreaker.ts new file mode 100644 index 00000000..01dd111f --- /dev/null +++ b/backend/src/services/circuitBreaker.ts @@ -0,0 +1,413 @@ +/** + * Circuit breaker for external service calls. + * + * Resilience pattern modelled on the resilience4j design: + * - closed: requests flow; a sliding window tracks outcomes. + * - open: requests are rejected (fast-fail) for `waitDurationInOpenState`. + * - half_open: a bounded number of trial calls is permitted after the wait + * elapses; enough consecutive successes restore `closed`. + * + * The implementation is a pure, self-contained state machine with an injectable + * clock so the whole lifecycle (open -> half_open -> closed) can be exercised in + * deterministic unit tests without real sleeps. It is intentionally decoupled + * from the Express middleware; see `middleware/circuit-breaker.ts` for the + * backward-compatible facade used by existing callers (stripe, stellar, ...). + */ + +export type CircuitBreakerState = 'closed' | 'open' | 'half_open'; + +export interface CircuitBreakerConfig { + /** Number of most-recent call outcomes retained for the failure-rate window. */ + slidingWindowSize: number; + /** Minimum number of calls within the window before the rate can trip the breaker. */ + minimumCallsToOpen: number; + /** Failure rate (0-100) above which the breaker opens once the window is saturated. */ + failureRateThreshold: number; + /** Consecutive-failure threshold; opens the breaker early regardless of window size. */ + failureThreshold: number; + /** Consecutive-success threshold required in half_open before re-closing. */ + successThreshold: number; + /** Time (ms) the breaker stays open before transitioning to half_open. */ + waitDurationInOpenState: number; + /** Max calls admitted while in half_open. */ + permittedNumberOfCallsInHalfOpenState: number; + /** Per-call timeout (ms). Overrides on a per-call basis via protect(..., timeoutMs). */ + requestTimeoutMs: number; + /** When true and the breaker has no recorded history, requests are rejected by default. */ + failClosed: boolean; + /** Error classes/functions whose thrown results count as failures (plus any error by default). */ + recordFailure?: (err: unknown) => boolean; + /** Error classes/functions to skip (not counted as a success or failure). */ + ignoreFailures?: (err: unknown) => boolean; +} + +export interface CircuitBreakerMetrics { + calls: number; + successfulCalls: number; + failedCalls: number; + timeoutCalls: number; + rejectedCalls: number; + halfOpenAttempts: number; + openedAt?: number; + state: CircuitBreakerState; + failureRate: number; + lastFailureAt?: number; + lastSuccessAt?: number; +} + +export interface CircuitBreakerSnapshot { + name: string; + state: CircuitBreakerState; + failures: number; + consecutiveFailures: number; + consecutiveSuccesses: number; + permittedCallsInHalfOpen: number; + config: Readonly; + metrics: Readonly; + wokenAt?: number; + openedAt?: number; +} + +/** Thrown when a call is short-circuited because the breaker is open. */ +export class CircuitBreakerError extends Error { + readonly serviceName: string; + readonly isTimeout: boolean; + + constructor(serviceName: string, message: string, isTimeout = false) { + super(message); + this.name = 'CircuitBreakerError'; + this.serviceName = serviceName; + this.isTimeout = isTimeout; + } +} + +const DEFAULT_CONFIG: CircuitBreakerConfig = { + slidingWindowSize: 20, + minimumCallsToOpen: 5, + failureRateThreshold: 50, + failureThreshold: 5, + successThreshold: 2, + waitDurationInOpenState: 60_000, + permittedNumberOfCallsInHalfOpenState: 3, + requestTimeoutMs: 10_000, + failClosed: false, +}; + +/** + * A single, isolated circuit. One instance guards one logical external service. + * Create instances via a registry or directly; they share no state with each + * other, which is a deliberate security boundary (a flapping service cannot + * trip the breaker of an unrelated service). + */ +export class CircuitBreaker { + readonly name: string; + readonly config: CircuitBreakerConfig; + /** Injectable time source so tests can advance the clock deterministically. */ + private now: () => number; + + private state: CircuitBreakerState = 'closed'; + private consecutiveFailures = 0; + private consecutiveSuccesses = 0; + private permittedCallsInHalfOpen = 0; + private reopenedAt?: number; + private wokenAt?: number; + private openedAt?: number; + + /** Sliding window of recent outcomes (true = success). */ + private outcomes: boolean[] = []; + + private metrics: CircuitBreakerMetrics = { + calls: 0, + successfulCalls: 0, + failedCalls: 0, + timeoutCalls: 0, + rejectedCalls: 0, + halfOpenAttempts: 0, + state: 'closed', + failureRate: 0, + }; + + private onStateChangeListeners: Array<(from: CircuitBreakerState, to: CircuitBreakerState) => void> = []; + + constructor(name: string, config: Partial = {}, now: () => number = Date.now) { + if (!name || !name.trim()) throw new Error('CircuitBreaker requires a non-empty name'); + this.name = name; + this.config = { ...DEFAULT_CONFIG, ...config }; + this.now = now; + + if (this.config.slidingWindowSize < 1) throw new Error('slidingWindowSize must be >= 1'); + if (this.config.failureRateThreshold < 0 || this.config.failureRateThreshold > 100) { + throw new Error('failureRateThreshold must be between 0 and 100'); + } + } + + /** Determine whether a call is permitted right now. */ + isCallPermitted(): boolean { + switch (this.state) { + case 'closed': + return true; + case 'open': { + const opened = this.openedAt ?? this.now(); + if (this.now() - opened >= this.config.waitDurationInOpenState) { + this.transitionToHalfOpen(); + return this.permitHalfOpenCall(); + } + this.metrics.rejectedCalls++; + return false; + } + case 'half_open': + return this.permitHalfOpenCall(); + } + } + + private permitHalfOpenCall(): boolean { + if (this.permittedCallsInHalfOpen < this.config.permittedNumberOfCallsInHalfOpenState) { + this.permittedCallsInHalfOpen++; + this.metrics.halfOpenAttempts++; + return true; + } + this.metrics.rejectedCalls++; + return false; + } + + private transitionToHalfOpen(): void { + if (this.state !== 'half_open') { + this.setState('half_open'); + } + this.wokenAt = this.now(); + this.permittedCallsInHalfOpen = 0; + this.consecutiveSuccesses = 0; + } + + private transitionToOpen(): void { + if (this.state !== 'open') { + this.setState('open'); + } + this.openedAt = this.now(); + this.reopenedAt = this.now(); + this.metrics.openedAt = this.now(); + this.consecutiveFailures = 0; + this.consecutiveSuccesses = 0; + this.permittedCallsInHalfOpen = 0; + } + + private transitionToClosed(): void { + if (this.state !== 'closed') { + this.setState('closed'); + } + this.consecutiveFailures = 0; + this.consecutiveSuccesses = 0; + this.permittedCallsInHalfOpen = 0; + this.openedAt = undefined; + this.wokenAt = undefined; + this.outcomes = []; + } + + private setState(next: CircuitBreakerState): void { + if (this.state === next) return; + const previous = this.state; + this.state = next; + this.metrics.state = next; + for (const listener of this.onStateChangeListeners) { + try { + listener(previous, next); + } catch { + // Listener failures must never break the breaker. + } + } + } + + /** Register a state-change observer (for logging, metrics, alerts). */ + onStateChange(listener: (from: CircuitBreakerState, to: CircuitBreakerState) => void): () => void { + this.onStateChangeListeners.push(listener); + return () => { + const i = this.onStateChangeListeners.indexOf(listener); + if (i >= 0) this.onStateChangeListeners.splice(i, 1); + }; + } + + /** Record a successful call outcome. */ + recordSuccess(): void { + this.metrics.calls++; + this.metrics.successfulCalls++; + this.metrics.lastSuccessAt = this.now(); + this.recordOutcome(true); + + if (this.state === 'half_open') { + this.consecutiveSuccesses++; + if (this.consecutiveSuccesses >= this.config.successThreshold) { + this.transitionToClosed(); + } + } else if (this.state === 'closed') { + this.consecutiveFailures = 0; + this.consecutiveSuccesses = 0; + } + } + + /** + * Record a failure. `err` is used to honor `ignoreFailures` / `recordFailure`; + * when the error is a timeout it is also tagged in the metrics. + */ + recordFailure(err: unknown, isTimeout = false): void { + if (this.config.ignoreFailures?.(err)) { + return; + } + if (this.config.recordFailure && !this.config.recordFailure(err)) { + // Caller opted to not count this as a short-circuit driver, but it is + // still an observable call for metrics. + this.metrics.calls++; + this.metrics.failedCalls++; + this.metrics.lastFailureAt = this.now(); + return; + } + + this.metrics.calls++; + this.metrics.failedCalls++; + this.metrics.lastFailureAt = this.now(); + if (isTimeout) this.metrics.timeoutCalls++; + this.recordOutcome(false); + + this.consecutiveFailures++; + this.consecutiveSuccesses = 0; + + if (this.state === 'half_open') { + this.transitionToOpen(); + return; + } + if (this.state === 'closed') { + if (this.consecutiveFailures >= this.config.failureThreshold) { + this.transitionToOpen(); + return; + } + if (this.shouldTripByFailureRate()) { + this.transitionToOpen(); + } + } + } + + private recordOutcome(success: boolean): void { + this.outcomes.push(success); + if (this.outcomes.length > this.config.slidingWindowSize) { + this.outcomes.shift(); + } + this.recomputeFailureRate(); + } + + private shouldTripByFailureRate(): boolean { + const window = this.outcomes; + if (window.length < this.config.minimumCallsToOpen) return false; + const failed = window.filter((o) => !o).length; + return (failed / window.length) * 100 >= this.config.failureRateThreshold; + } + + private recomputeFailureRate(): void { + if (this.outcomes.length === 0) { + this.metrics.failureRate = 0; + return; + } + const failed = this.outcomes.filter((o) => !o).length; + this.metrics.failureRate = Math.round((failed / this.outcomes.length) * 100); + } + + /** + * Execute `fn` guarded by the breaker. When the breaker is open and `fallback` + * is provided, the fallback is invoked instead of throwing. Otherwise a + * `CircuitBreakerError` is thrown while the breaker is open. + */ + async protect(fn: () => Promise | T, fallback?: () => Promise | T, requestTimeoutMs?: number): Promise { + if (!this.isCallPermitted()) { + if (fallback) return fallback(); + throw new CircuitBreakerError(this.name, `Circuit ${this.name} is open`); + } + + const timeoutMs = requestTimeoutMs ?? this.config.requestTimeoutMs; + if (timeoutMs <= 0) { + return this.runCall(fn, fallback); + } + + let timedOut = false; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + timedOut = true; + reject(new CircuitBreakerError(this.name, `Call to ${this.name} timed out after ${timeoutMs}ms`, true)); + }, timeoutMs); + }); + + try { + const result = await Promise.race([Promise.resolve().then(() => fn()), timeoutPromise]); + this.recordSuccess(); + return result; + } catch (error) { + if (error instanceof CircuitBreakerError && (error as CircuitBreakerError).isTimeout && timedOut) { + this.recordFailure(error, true); + if (fallback) return fallback(); + throw error; + } + if (error instanceof CircuitBreakerError) { + // User thrown CircuitBreakerError was not a real timeout (e.g. from fallback path) — rethrow. + throw error; + } + this.recordFailure(error, false); + if (fallback) return fallback(); + throw error; + } + } + + private async runCall(fn: () => Promise | T, fallback?: () => Promise | T): Promise { + try { + const result = await Promise.resolve().then(() => fn()); + this.recordSuccess(); + return result; + } catch (error) { + if (error instanceof CircuitBreakerError) { + // A nested/foreign breaker's rejection is not this circuit's failure. + throw error; + } + this.recordFailure(error, false); + if (fallback) return fallback(); + throw error; + } + } + + /** Force the breaker back to a closed state and clear history/metrics. */ + reset(): void { + this.outcomes = []; + this.transitionToClosed(); + this.metrics.calls = 0; + this.metrics.successfulCalls = 0; + this.metrics.failedCalls = 0; + this.metrics.timeoutCalls = 0; + this.metrics.rejectedCalls = 0; + this.metrics.halfOpenAttempts = 0; + this.metrics.openedAt = undefined; + this.metrics.lastFailureAt = undefined; + this.metrics.lastSuccessAt = undefined; + this.metrics.failureRate = 0; + } + + /** Reset outcome history + metrics but keep the current state. */ + resetMetrics(): void { + this.outcomes = []; + this.recomputeFailureRate(); + this.consecutiveFailures = 0; + this.consecutiveSuccesses = 0; + } + + /** Serialisable snapshot for observability and the management API. */ + snapshot(): CircuitBreakerSnapshot { + return { + name: this.name, + state: this.state, + failures: this.consecutiveFailures, + consecutiveFailures: this.consecutiveFailures, + consecutiveSuccesses: this.consecutiveSuccesses, + permittedCallsInHalfOpen: this.config.permittedNumberOfCallsInHalfOpenState - this.permittedCallsInHalfOpen, + config: { ...this.config }, + metrics: { ...this.metrics }, + openedAt: this.openedAt, + wokenAt: this.wokenAt, + }; + } +} + +export { DEFAULT_CONFIG }; diff --git a/backend/src/services/circuitBreakerRegistry.ts b/backend/src/services/circuitBreakerRegistry.ts new file mode 100644 index 00000000..e75c0c86 --- /dev/null +++ b/backend/src/services/circuitBreakerRegistry.ts @@ -0,0 +1,73 @@ +/** + * A small registry that owns named `CircuitBreaker` instances. + * + * The registry is a convenience for the middleware/route layer so services can + * be addressed by a stable name (e.g. `stripe-api`, `stellar-horizon`) without + * passing instances around. Every breaker is an isolated instance; the registry + * does not share any state between named entries. + */ + +import { CircuitBreaker, type CircuitBreakerConfig } from './circuitBreaker.js'; + +export class CircuitBreakerRegistry { + private readonly breakers = new Map(); + private readonly now: () => number; + + constructor(now: () => number = Date.now) { + this.now = now; + } + + /** Get an existing breaker or create it with the given config. */ + get(name: string, config: Partial = {}): CircuitBreaker { + let breaker = this.breakers.get(name); + if (!breaker) { + breaker = this.create(name, config); + } + return breaker; + } + + /** Get an existing breaker or `undefined` (does not create). */ + getIfPresent(name: string): CircuitBreaker | undefined { + return this.breakers.get(name); + } + + /** Create (or replace) a breaker under `name`. */ + create(name: string, config: Partial = {}): CircuitBreaker { + const breaker = new CircuitBreaker(name, config, this.now); + this.breakers.set(name, breaker); + return breaker; + } + + has(name: string): boolean { + return this.breakers.has(name); + } + + reset(name: string): boolean { + const breaker = this.breakers.get(name); + if (!breaker) return false; + breaker.reset(); + return true; + } + + resetAll(): void { + for (const breaker of this.breakers.values()) { + breaker.reset(); + } + } + + delete(name: string): boolean { + return this.breakers.delete(name); + } + + clear(): void { + this.breakers.clear(); + } + + names(): string[] { + return Array.from(this.breakers.keys()); + } + + snapshots(): Array> { + return Array.from(this.breakers.values()).map((b) => b.snapshot()); + } +} diff --git a/backend/src/services/cors.ts b/backend/src/services/cors.ts new file mode 100644 index 00000000..af0ca22b --- /dev/null +++ b/backend/src/services/cors.ts @@ -0,0 +1,336 @@ +/** + * services/cors.ts — Dynamic CORS origin whitelist. + * + * Runtime-mutable allowlist of browser origins that may call the API + * cross-origin. Supports: + * + * - Exact origins: `https://app.example.com` + * - Subdomain wildcard: `https://*.example.com` (matches the apex too) + * - Scheme-relative: `example.com` / `*.example.com` (any scheme) + * - Open mode: `*` + * + * The allowlist can be mutated at runtime (add/remove/set) and refreshed + * from an async loader, so policies change without a redeploy. Every origin + * decision is tracked in metrics for observability. + * + * Semantic notes: + * - Matching ignores the port: `https://app.example.com:8443` matches the + * pattern `https://app.example.com`. Ports are rarely meaningful for + * CORS policy and ignoring them avoids spurious denials. + * - Scheme and hostname are compared case-insensitively. + * - `Origin: null` (and empty origins) are never allowed; browsers use + * `null` for sandboxed or file:// contexts. + * - An empty allowlist denies everything. Use `*` to allow any origin. + */ + +export interface CorsPolicyOptions { + /** Initial allowlist entries (origins or patterns). */ + allowedOrigins?: string[]; + /** Reflect `Access-Control-Allow-Credentials`. */ + allowCredentials?: boolean; + /** Async source of truth used by `refresh()`. */ + loader?: () => Promise; +} + +export interface CorsMetrics { + /** Requests whose Origin was allowed. */ + allowedRequests: number; + /** Requests whose Origin was present but denied. */ + deniedRequests: number; + /** Preflight (OPTIONS + Access-Control-Request-Method) requests handled. */ + preflights: number; + /** Preflight requests denied at the origin check. */ + preflightDenied: number; +} + +/** Thrown when an origin/pattern fails syntax validation or the loader errors. */ +export class CorsPolicyError extends Error { + readonly code = 'INVALID_CORS_ORIGIN'; + + constructor(pattern: string) { + super(`Invalid CORS origin or pattern: "${pattern}"`); + this.name = 'CorsPolicyError'; + } +} + +const MAX_PATTERN_LENGTH = 200; +const ORIGIN_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i; +// eslint-disable-next-line no-control-regex -- CORS origins must reject control characters outright +const FORBIDDEN_CHARS_RE = /[\s\u0000-\u001f\u007f]/; + +// ─── Validation ─────────────────────────────────────────────────────────────── + +/** + * `true` when `origin` is a well-formed `scheme://host[:port]` with no path, + * query, fragment, whitespace, or control characters. + */ +export function isValidOrigin(origin: string): boolean { + if (typeof origin !== 'string' || origin.length === 0 || origin.length > MAX_PATTERN_LENGTH) { + return false; + } + if (!ORIGIN_SCHEME_RE.test(origin) || FORBIDDEN_CHARS_RE.test(origin)) { + return false; + } + const rest = origin.split('://', 2)[1]; + if (!rest || rest.includes('/') || rest.includes('?') || rest.includes('#')) { + return false; + } + return hostOf(origin).length > 0; +} + +/** + * `true` when `pattern` is a usable allowlist entry: a valid origin or a + * scheme-relative host (optionally with a `*.` subdomain prefix). The bare + * `*` wildcard is reserved for allowlist semantics, not validation. + */ +export function isValidPattern(pattern: unknown): pattern is string { + if (typeof pattern !== 'string' || pattern.length === 0 || pattern.length > MAX_PATTERN_LENGTH) { + return false; + } + const trimmed = pattern.trim().toLowerCase(); + if (FORBIDDEN_CHARS_RE.test(trimmed)) return false; + if (trimmed === '*') return true; + if (trimmed.includes('://')) { + return isValidOrigin(pattern) && validPatternHost(hostOf(trimmed)); + } + // Scheme-relative host, optionally wildcarded. + if (trimmed.includes('/') || trimmed.includes('?') || trimmed.includes('#')) return false; + return validPatternHost(trimmed.startsWith('*.') ? trimmed.slice(2) : trimmed); +} + +/** Host portion of a pattern: letters/numbers/dots/hyphens, no port; a `*` + * is only allowed as the leading `*.` subdomain prefix. */ +function validPatternHost(host: string): boolean { + if (host.length === 0) return false; + if (/[^a-z0-9.*-]/.test(host) || host.includes(':') || host.startsWith('.')) return false; + const starIndex = host.indexOf('*'); + if (starIndex !== -1) { + if (!(host.startsWith('*.') && starIndex === 0)) return false; + if (host.slice(2).includes('*')) return false; + } + return true; +} + +// ─── Matching ───────────────────────────────────────────────────────────────── + +function hostOf(origin: string): string { + const after = origin.split('://', 2)[1] ?? ''; + return after.split(':')[0]; +} + +function schemeOf(origin: string): string { + const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(origin); + return match ? match[1].toLowerCase() : ''; +} + +function hostMatches(subject: string, wildcardTarget: string): boolean { + const target = wildcardTarget.toLowerCase().replace(/^\./, ''); + if (target.startsWith('*.')) { + const suffix = target.slice(2); + return subject === suffix || subject.endsWith(`.${suffix}`); + } + return subject === target; +} + +/** + * `true` when `origin` satisfies the allowlist `pattern`. + * + * @see top-of-file block for supported pattern forms. + */ +export function originMatches(pattern: string, origin: string): boolean { + if (pattern === '*') return true; + if (!isValidOrigin(origin)) return false; + + const p = pattern.trim().toLowerCase(); + const o = origin.toLowerCase(); + + if (p.startsWith('*.')) { + return hostMatches(hostOf(o), p); + } + if (!p.includes('://')) { + return hostOf(o) === p; + } + if (schemeOf(p) !== schemeOf(o)) return false; + return hostMatches(hostOf(o), hostOf(p)); +} + +// ─── Policy ─────────────────────────────────────────────────────────────────── + +const metrics: CorsMetrics = { + allowedRequests: 0, + deniedRequests: 0, + preflights: 0, + preflightDenied: 0, +}; + +export class CORSOriginPolicy { + private origins = new Set(); + private allowCredentials: boolean; + private loader?: () => Promise; + private revision = 0; + + constructor(options: CorsPolicyOptions = {}) { + this.allowCredentials = options.allowCredentials ?? false; + this.loader = options.loader; + if (options.allowedOrigins) { + this.set(options.allowedOrigins); + } + } + + /** Replace the whole allowlist. Throws `CorsPolicyError` on a bad entry. */ + set(allowedOrigins: string[]): void { + const next = new Set(); + for (const entry of allowedOrigins) { + const trimmed = typeof entry === 'string' ? entry.trim() : ''; + if (trimmed === '' || !isValidPattern(trimmed)) { + throw new CorsPolicyError(entry); + } + next.add(trimmed.toLowerCase()); + } + this.origins = next; + this.revision++; + } + + /** Add a single origin/pattern. Returns the new allowlist size. */ + add(pattern: string): number { + const trimmed = pattern.trim(); + if (trimmed === '' || !isValidPattern(trimmed)) { + throw new CorsPolicyError(pattern); + } + this.origins.add(trimmed.toLowerCase()); + this.revision++; + return this.origins.size; + } + + /** Remove a single origin/pattern. Returns `true` if it was present. */ + remove(pattern: string): boolean { + const removed = this.origins.delete(pattern.trim().toLowerCase()); + if (removed) this.revision++; + return removed; + } + + /** Current allowlist entries, sorted for deterministic output. */ + list(): string[] { + return Array.from(this.origins).sort(); + } + + /** `true` when the allowlist contains the `*` entry (concede any origin). */ + get wildcard(): boolean { + return this.origins.has('*'); + } + + /** When credentials are reflected alongside allowed origins. */ + get credentials(): boolean { + return this.allowCredentials; + } + + /** Toggle credential reflection at runtime. */ + setCredentials(allow: boolean): void { + this.allowCredentials = allow; + this.revision++; + } + + /** Monotonic revision counter; increments on every mutation. */ + get version(): number { + return this.revision; + } + + get size(): number { + return this.origins.size; + } + + /** + * `true` when `origin` is admitted by this policy. Counts allow/deny in + * the shared metrics. `null`/empty/sandbox origins are never allowed. + */ + isAllowed(origin: string | null | undefined): boolean { + if (origin == null || origin === '' || origin === 'null' || !isValidOrigin(origin)) { + metrics.deniedRequests++; + return false; + } + if (this.wildcard) { + metrics.allowedRequests++; + return true; + } + for (const pattern of this.origins) { + if (originMatches(pattern, origin)) { + metrics.allowedRequests++; + return true; + } + } + metrics.deniedRequests++; + return false; + } + + /** + * Re-pull the allowlist from the loader. Fails safe: the previous list is + * kept untouched if the loader rejects or returns invalid entries. + */ + async refresh(): Promise { + if (!this.loader) { + throw new CorsPolicyError(this.list().join(',') || '*'); + } + const next = await this.loader(); + this.set(next); + return this.list(); + } +} + +let defaultPolicy: CORSOriginPolicy | undefined; + +/** The shared policy used by middleware, routes, and management endpoints. */ +export function getCorsPolicy(): CORSOriginPolicy { + if (!defaultPolicy) { + defaultPolicy = new CORSOriginPolicy(); + } + return defaultPolicy; +} + +/** Replace the shared policy (used at boot and in tests). */ +export function initCorsPolicy(options: CorsPolicyOptions = {}): CORSOriginPolicy { + defaultPolicy = new CORSOriginPolicy(options); + return defaultPolicy; +} + +// ─── Convenience wrappers over the shared policy ───────────────────────────── + +export function addAllowedOrigin(pattern: string): number { + return getCorsPolicy().add(pattern); +} + +export function removeAllowedOrigin(pattern: string): boolean { + return getCorsPolicy().remove(pattern); +} + +export function setAllowedOrigins(patterns: string[]): void { + getCorsPolicy().set(patterns); +} + +export function getAllowedOrigins(): string[] { + return getCorsPolicy().list(); +} + +export function isOriginAllowed(origin: string | null | undefined): boolean { + return getCorsPolicy().isAllowed(origin); +} + +/** Record a handled preflight request against the shared metrics. */ +export function recordPreflight(allowed: boolean): void { + metrics.preflights++; + if (!allowed) metrics.preflightDenied++; +} + +export async function refreshAllowedOrigins(): Promise { + return getCorsPolicy().refresh(); +} + +export function getCorsMetrics(): CorsMetrics { + return { ...metrics }; +} + +export function resetCorsMetrics(): void { + metrics.allowedRequests = 0; + metrics.deniedRequests = 0; + metrics.preflights = 0; + metrics.preflightDenied = 0; +} \ No newline at end of file diff --git a/backend/src/services/webhookKeys.ts b/backend/src/services/webhookKeys.ts new file mode 100644 index 00000000..baccb373 --- /dev/null +++ b/backend/src/services/webhookKeys.ts @@ -0,0 +1,525 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +export const WEBHOOK_KEY_SCHEME = 'v1'; +export const DEFAULT_TOLERANCE_SECONDS = 300; +export const DEFAULT_OVERLAP_SECONDS = 72 * 60 * 60; +export const DEFAULT_RETENTION_SECONDS = 7 * 24 * 60 * 60; +export const MIN_WEBHOOK_KEY_SECRET_LENGTH = 32; + +export type WebhookKeyStatus = 'active' | 'retiring' | 'revoked'; + +export interface WebhookKeyRecord { + keyId: string; + provider: string; + secret: string; + algorithm: 'sha256'; + status: WebhookKeyStatus; + createdAt: number; + retiredAt?: number; + revokedAt?: number; + expiresAt?: number; + lastUsedAt?: number; + label?: string; +} + +export interface WebhookKeyGenerationInput { + provider?: string; + secret?: string; + keyId?: string; + label?: string; + expiresAt?: number; +} + +export interface SignWebhookPayloadInput { + provider?: string; + keyId?: string; + body: string | Buffer; + timestamp?: number; +} + +export interface SignWebhookResult { + signature: string; + version: string; + timestamp: string; + keyId: string; +} + +export interface VerifyWebhookSignatureInput { + signature: string; + timestamp: string | number; + body: string | Buffer; + provider?: string; + keyId?: string; + toleranceSeconds?: number; +} + +export type WebhookRejectionReason = + | 'missing_signature' + | 'missing_timestamp' + | 'invalid_signature_format' + | 'timestamp_out_of_tolerance' + | 'no_keys' + | 'unknown_key' + | 'key_revoked' + | 'key_expired' + | 'signature_mismatch'; + +export interface VerifyWebhookSignatureResult { + isValid: boolean; + provider?: string; + keyId?: string; + timestamp: number; + ageMs: number; + error?: string; + reason?: WebhookRejectionReason; +} + +export interface WebhookKeyRegistryConfig { + now?: () => number; + overlapSeconds?: number; + retentionSeconds?: number; + toleranceSeconds?: number; + keys?: WebhookKeyGenerationInput[]; +} + +export interface WebhookKeyMetrics { + registered: number; + rotated: number; + revoked: number; + purged: number; + signs: number; + signErrors: number; + verifications: number; + verified: number; + rejected: number; + rejectionsByReason: Partial>; +} + +export class WebhookKeyError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'WebhookKeyError'; + } +} + +const DIGEST_PREFIXES = ['v1=', 'sha256=', 'sig-sha256=', 'sha256-']; +const ALL_HEX_DIGEST = /^[a-f0-9]{64}$/i; + +function formatId(provider: string | undefined, prefix: string): string { + const providerPart = provider ? `${provider}_` : ''; + const rand = randomBytes(5).toString('hex'); + return `${prefix}${providerPart}${Date.now().toString(36)}_${rand}`; +} + +export function generateWebhookKeySecret(): string { + return randomBytes(32).toString('base64url'); +} + +export function buildWebhookDigest(body: string | Buffer, secret: string, timestampSeconds: number): string { + const raw = Buffer.isBuffer(body) ? body.toString('utf8') : body; + return createHmac('sha256', secret) + .update(`${timestampSeconds}.${raw}`) + .digest('hex'); +} + +export function constantTimeEqualHex(a: string, b: string): boolean { + try { + const ba = Buffer.from(a, 'hex'); + const bb = Buffer.from(b, 'hex'); + return ba.length === bb.length && timingSafeEqual(ba, bb); + } catch { + return false; + } +} + +export function parseWebhookSignature(signature: string): { keyId?: string; digest: string } | null { + let value = signature.trim(); + for (const prefix of DIGEST_PREFIXES) { + if (value.startsWith(prefix)) { + value = value.slice(prefix.length); + break; + } + } + if (ALL_HEX_DIGEST.test(value)) { + return { digest: value.toLowerCase() }; + } + const dot = value.lastIndexOf('.'); + if (dot > 0 && dot < value.length - 1) { + const keyId = value.slice(0, dot); + const digest = value.slice(dot + 1); + if (ALL_HEX_DIGEST.test(digest)) { + return { keyId, digest: digest.toLowerCase() }; + } + } + return null; +} + +export function normalizeWebhookTimestamp(timestamp: string | number): number | null { + if (typeof timestamp === 'number' && Number.isFinite(timestamp)) { + return timestamp <= 1e11 ? timestamp * 1000 : timestamp; + } + if (typeof timestamp === 'string') { + const trimmed = timestamp.trim(); + if (trimmed === '') return null; + if (/^-?\d+$/.test(trimmed)) { + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + return numeric <= 1e11 ? numeric * 1000 : numeric; + } + } + const parsed = Date.parse(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +export class WebhookKeyRegistry { + readonly overlapSeconds: number; + readonly retentionSeconds: number; + readonly toleranceSeconds: number; + + private readonly now: () => number; + private readonly keys = new Map(); + private mRegistered = 0; + private mRotated = 0; + private mRevoked = 0; + private mPurged = 0; + private mSigns = 0; + private mSignErrors = 0; + private mVerifications = 0; + private mVerified = 0; + private mRejected = 0; + private readonly mRejections: Partial> = {}; + + constructor(config: WebhookKeyRegistryConfig = {}) { + this.now = config.now ?? (() => Date.now()); + this.overlapSeconds = config.overlapSeconds ?? DEFAULT_OVERLAP_SECONDS; + this.retentionSeconds = config.retentionSeconds ?? DEFAULT_RETENTION_SECONDS; + this.toleranceSeconds = config.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS; + for (const key of config.keys ?? []) { + this.register(key); + } + } + + get nowMs(): number { + return this.now(); + } + + get size(): number { + return this.keys.size; + } + + register(input: WebhookKeyGenerationInput = {}): WebhookKeyRecord { + const provider = input.provider ?? ''; + const secret = input.secret ?? generateWebhookKeySecret(); + if (secret.length < MIN_WEBHOOK_KEY_SECRET_LENGTH) { + throw new WebhookKeyError('WEBHOOK_KEY_SECRET_TOO_SHORT', `Secret must be at least ${MIN_WEBHOOK_KEY_SECRET_LENGTH} characters`); + } + const keyId = input.keyId ?? formatId(provider || undefined, 'wvk_'); + if (this.keys.has(keyId)) { + throw new WebhookKeyError('WEBHOOK_KEY_ID_COLLISION', `Key ${keyId} already exists`); + } + const record: WebhookKeyRecord = { + keyId, + provider, + secret, + algorithm: 'sha256', + status: 'active', + createdAt: this.nowMs, + expiresAt: input.expiresAt, + label: input.label, + }; + this.keys.set(keyId, record); + this.mRegistered += 1; + return record; + } + + rotate(input: { provider?: string; secret?: string; keyId?: string; label?: string; overlapSeconds?: number } = {}): { + retired?: WebhookKeyRecord; + active: WebhookKeyRecord; + } { + const provider = input.provider ?? ''; + const overlapMs = (input.overlapSeconds ?? this.overlapSeconds) * 1000; + const current = this.activeKeysFor(provider)[0]; + if (current) { + current.status = 'retiring'; + current.retiredAt = this.nowMs; + current.expiresAt = this.nowMs + overlapMs; + this.keys.set(current.keyId, current); + } + const active = this.register({ + provider, + secret: input.secret, + keyId: input.keyId, + label: input.label, + }); + this.mRotated += 1; + return { retired: current, active }; + } + + revoke(keyId: string): boolean { + const record = this.keys.get(keyId); + if (!record) return false; + if (record.status === 'active' && this.activeKeysFor(record.provider).length === 1) { + throw new WebhookKeyError( + 'WEBHOOK_KEY_LAST_ACTIVE', + `Cannot revoke the only active key for provider "${record.provider}"; rotate first`, + ); + } + record.status = 'revoked'; + record.revokedAt = this.nowMs; + this.keys.set(keyId, record); + this.mRevoked += 1; + return true; + } + + sign(input: SignWebhookPayloadInput): SignWebhookResult { + let record: WebhookKeyRecord | undefined; + if (input.keyId) { + record = this.keys.get(input.keyId); + if (!record) { + this.mSignErrors += 1; + throw new WebhookKeyError('WEBHOOK_KEY_NOT_FOUND', `Signing key ${input.keyId} does not exist`); + } + } else { + record = this.activeKeysFor(input.provider)[0]; + if (!record) { + this.mSignErrors += 1; + throw new WebhookKeyError('WEBHOOK_KEY_NO_ACTIVE', `No active key for provider "${input.provider ?? ''}"`); + } + } + if (record.status !== 'active') { + this.mSignErrors += 1; + throw new WebhookKeyError('WEBHOOK_KEY_NOT_ACTIVE', `Key ${record.keyId} is not active (${record.status})`); + } + const timestampSeconds = input.timestamp ?? Math.floor(this.nowMs / 1000); + const digest = buildWebhookDigest(input.body, record.secret, timestampSeconds); + const signature = input.keyId ? `v1=${record.keyId}.${digest}` : `v1=${digest}`; + this.mSigns += 1; + record.lastUsedAt = this.nowMs; + this.keys.set(record.keyId, record); + return { + signature, + version: WEBHOOK_KEY_SCHEME, + timestamp: String(timestampSeconds), + keyId: record.keyId, + }; + } + + verify(input: VerifyWebhookSignatureInput): VerifyWebhookSignatureResult { + this.mVerifications += 1; + const reject = (reason: WebhookRejectionReason, error: string | undefined, timestamp = 0, ageMs = 0): VerifyWebhookSignatureResult => { + this.mRejected += 1; + this.mRejections[reason] = (this.mRejections[reason] ?? 0) + 1; + return { + isValid: false, + provider: input.provider, + timestamp, + ageMs, + error: error ?? '', + reason, + }; + }; + + if (!input.signature) { + return reject('missing_signature', 'Missing webhook signature'); + } + const parsedSignature = parseWebhookSignature(input.signature); + if (!parsedSignature) { + return reject('invalid_signature_format', 'Signature is not in a recognized format'); + } + const timestampMs = normalizeWebhookTimestamp(input.timestamp); + if (timestampMs === null) { + return reject('missing_timestamp', 'Missing or unparseable webhook timestamp'); + } + + const now = this.nowMs; + const ageMs = Math.abs(now - timestampMs); + const toleranceMs = (input.toleranceSeconds ?? this.toleranceSeconds) * 1000; + if (ageMs > toleranceMs) { + return reject( + 'timestamp_out_of_tolerance', + `Timestamp outside tolerance window (${ageMs}ms > ${toleranceMs}ms)`, + timestampMs, + ageMs, + ); + } + + const candidates = this.resolveCandidates( + input.keyId ?? parsedSignature.keyId, + input.provider, + this.nowMs, + ); + if (candidates.reason) { + return reject(candidates.reason, candidates.error, timestampMs, ageMs); + } + if (candidates.keys.length === 0) { + return reject('no_keys', `No usable key for provider "${input.provider ?? ''}"`, timestampMs, ageMs); + } + + const timestampSeconds = Math.floor(timestampMs / 1000); + for (const record of candidates.keys) { + if (constantTimeEqualHex(parsedSignature.digest, buildWebhookDigest(input.body, record.secret, timestampSeconds))) { + record.lastUsedAt = now; + this.keys.set(record.keyId, record); + this.mVerified += 1; + return { + isValid: true, + provider: input.provider ?? record.provider, + keyId: record.keyId, + timestamp: timestampMs, + ageMs, + }; + } + } + + return reject('signature_mismatch', 'Signature verification failed', timestampMs, ageMs); + } + + private resolveCandidates( + keyId: string | undefined, + provider: string | undefined, + now: number, + ): { keys: WebhookKeyRecord[]; reason?: WebhookRejectionReason; error?: string } { + if (keyId) { + const record = this.keys.get(keyId); + if (!record) { + return { keys: [], reason: 'unknown_key', error: `Key ${keyId} does not exist` }; + } + if (provider && record.provider !== provider) { + return { keys: [], reason: 'unknown_key', error: `Key ${keyId} does not belong to provider "${provider}"` }; + } + if (record.status === 'revoked') { + return { keys: [], reason: 'key_revoked', error: `Key ${keyId} has been revoked` }; + } + if (record.expiresAt !== undefined && record.expiresAt <= now) { + return { keys: [], reason: 'key_expired', error: `Key ${keyId} has expired` }; + } + return { keys: [record] }; + } + + const keys = Array.from(this.keys.values()) + .filter((record) => { + if (provider && record.provider !== provider) return false; + if (record.status === 'revoked') return false; + if (record.expiresAt !== undefined && record.expiresAt <= now) return false; + return record.status === 'active' || record.status === 'retiring'; + }) + .sort((a, b) => { + if (a.status === 'active' && b.status !== 'active') return -1; + if (b.status === 'active' && a.status !== 'active') return 1; + return b.createdAt - a.createdAt; + }); + return { keys }; + } + + getKey(keyId: string): WebhookKeyRecord | undefined { + return this.keys.get(keyId); + } + + getActiveKey(provider?: string): WebhookKeyRecord | undefined { + return this.activeKeysFor(provider)[0]; + } + + activeKeysFor(provider?: string): WebhookKeyRecord[] { + return Array.from(this.keys.values()) + .filter((record) => record.status === 'active') + .filter((record) => !provider || record.provider === provider) + .sort((a, b) => b.createdAt - a.createdAt); + } + + hasKeysForProvider(provider?: string): boolean { + return Array.from(this.keys.values()).some((record) => { + if (provider && record.provider !== provider) return false; + return record.status === 'active' || record.status === 'retiring'; + }); + } + + listKeys(input: { provider?: string; status?: WebhookKeyStatus; includeRevoked?: boolean } = {}): WebhookKeyRecord[] { + return Array.from(this.keys.values()) + .filter((record) => !input.provider || record.provider === input.provider) + .filter((record) => !input.status || record.status === input.status) + .filter((record) => input.includeRevoked || record.status !== 'revoked') + .sort((a, b) => b.createdAt - a.createdAt); + } + + purgeExpired(): number { + const now = this.nowMs; + this.purgeEntirelyExpired(now); + let purged = 0; + for (const [keyId, record] of this.keys) { + if (record.status === 'active') continue; + const reference = record.retiredAt ?? record.revokedAt ?? record.expiresAt; + if (reference !== undefined && now - reference >= this.retentionSeconds * 1000) { + this.keys.delete(keyId); + purged += 1; + } + } + this.mPurged += purged; + return purged; + } + + private purgeEntirelyExpired(now: number): void { + for (const [keyId, record] of this.keys) { + if (record.expiresAt !== undefined && record.expiresAt <= now && now - record.expiresAt >= this.retentionSeconds * 1000) { + this.keys.delete(keyId); + this.mPurged += 1; + } + } + } + + metrics(): WebhookKeyMetrics { + return { + registered: this.mRegistered, + rotated: this.mRotated, + revoked: this.mRevoked, + purged: this.mPurged, + signs: this.mSigns, + signErrors: this.mSignErrors, + verifications: this.mVerifications, + verified: this.mVerified, + rejected: this.mRejected, + rejectionsByReason: { ...this.mRejections }, + }; + } + + resetMetrics(): void { + this.mRegistered = 0; + this.mRotated = 0; + this.mRevoked = 0; + this.mPurged = 0; + this.mSigns = 0; + this.mSignErrors = 0; + this.mVerifications = 0; + this.mVerified = 0; + this.mRejected = 0; + for (const reason of Object.keys(this.mRejections) as WebhookRejectionReason[]) { + delete this.mRejections[reason]; + } + } + + clear(): void { + this.keys.clear(); + } +} + +let sharedRegistry: WebhookKeyRegistry | undefined; + +export function getWebhookKeyRegistry(): WebhookKeyRegistry { + if (!sharedRegistry) { + sharedRegistry = new WebhookKeyRegistry(); + } + return sharedRegistry; +} + +export function initWebhookKeyRegistry(config: WebhookKeyRegistryConfig = {}): WebhookKeyRegistry { + sharedRegistry = new WebhookKeyRegistry(config); + return sharedRegistry; +} + +export function resetWebhookKeyRegistry(): void { + sharedRegistry = undefined; +} \ No newline at end of file diff --git a/backend/src/tests/benchmarks/benchmark-app.ts b/backend/src/tests/benchmarks/benchmark-app.ts index a3d4372f..e10d0053 100644 --- a/backend/src/tests/benchmarks/benchmark-app.ts +++ b/backend/src/tests/benchmarks/benchmark-app.ts @@ -2,6 +2,11 @@ * Self-contained Express app for benchmarks (no Prisma, Stellar, or job scheduler). */ import express from 'express'; +import { etag } from '../../middleware/etag.js'; +import { cacheControl } from '../../middleware/cache.js'; +import { createCorsMiddleware } from '../../middleware/cors.js'; +import { WebhookKeyRegistry } from '../../services/webhookKeys.js'; +import { CircuitBreaker as BenchmarkCircuitBreaker } from '../../services/circuitBreaker.js'; const escrows: Array> = []; const payments = new Map>(); @@ -38,8 +43,34 @@ export function createBenchmarkApp(): express.Application { res.status(201).json(escrow); }); - api.get('/circuit-breaker', (_req, res) => { - res.json({ circuits: [], status: 'closed' }); + // ── Circuit breaker benchmarks ──────────────────────────────────────────── + // Guard a realistic logical "upstream" call with the breaker. The closed + // route exercises the per-request overhead (permit check + outcome record); + // the rejected route exercises the fast-fail 503 path while the circuit is + // open. A dedicated local breaker keeps the benchmark independent of the + // shared registry used by other suites. + const benchCircuit = new BenchmarkCircuitBreaker('benchmark', { + failureThreshold: 100, + waitDurationInOpenState: 30_000, + requestTimeoutMs: 0, + }); + const benchOpenCircuit = new BenchmarkCircuitBreaker('benchmark-open', { + failureThreshold: 1, + waitDurationInOpenState: 30_000, + requestTimeoutMs: 0, + }); + benchOpenCircuit.recordFailure(new Error('bench-open')); + api.get('/circuit-breaker', async (_req, res) => { + await benchCircuit.protect(async () => {}); + res.json({ state: 'closed', status: 'ok' }); + }); + api.get('/circuit-breaker/rejected', async (_req, res) => { + const allowed = benchOpenCircuit.isCallPermitted(); + if (!allowed) { + res.status(503).json({ error: { code: 'CIRCUIT_OPEN', status: 503 } }); + return; + } + res.json({ state: 'closed', status: 'ok' }); }); api.get('/compression/metrics', (_req, res) => { @@ -66,6 +97,112 @@ export function createBenchmarkApp(): express.Application { res.json({ success: true, payment }); }); + // ── Response caching benchmarks ────────────────────────────────────────── + // Same logical body for every cache strategy so timings are comparable. + api.get('/cache/plain', (_req, res) => { + res.json({ cache: 'none', timestamp: Date.now() }); + }); + + api.get( + '/cache/header', + cacheControl({ maxAge: 300 }), + (_req, res) => { + res.json({ cache: 'header', timestamp: Date.now() }); + }, + ); + + api.get( + '/cache/memory', + cacheControl({ maxAge: 300, inMemory: true }), + (_req, res) => { + res.json({ cache: 'memory', timestamp: Date.now() }); + }, + ); + + // Always-conditional route: wildcard If-None-Match forces a 304 fast path. + api.get( + '/cache/etag-304', + etag(), + (_req, res) => { + res.json({ cache: 'etag' }); + }, + ); + + // ── CORS benchmarks ────────────────────────────────────────────────────── + // Dynamic-whitelist middleware resolving an exact origin and a wildcard + // tenant pattern per request. Mounted router-level so preflights are + // answered by the middleware (as in the real app) before route matching. + const corsMiddleware = createCorsMiddleware({ + allowedOrigins: ['https://app.example.com', 'https://*.tenant.example.com'], + credentials: true, + allowedHeaders: ['Content-Type', 'Authorization'], + }); + + api.use('/cors', corsMiddleware); + + api.get( + '/cors/allowed', + (_req, res) => { + res.json({ cors: 'allowed', timestamp: Date.now() }); + }, + ); + + // ── Webhook signature verification benchmarks ────────────────────────── + // Rotation-aware HMAC verification against a registered key. A valid + // signature/timestamp pair is precomputed at boot and replayed for the + // "valid" route; the invalid route signs over a different body so every + // request exercises the constant-time rejection path. + const BENCH_WEBHOOK_BODY = JSON.stringify({ event: 'bench.webhook', data: { id: 'bench_w_1' } }); + const BENCH_WEBHOOK_BODY_INVALID = JSON.stringify({ event: 'bench.webhook', data: { id: 'bench_w_2' } }); + const BENCH_WEBHOOK_TS = String(Math.floor(Date.now() / 1000)); + const benchKeyRegistry = new WebhookKeyRegistry({ + keys: [{ provider: 'custom', secret: 'bench_webhook_secret_0123456789abcdef0123456789abcdef' }], + }); + const benchWebhookSignature = benchKeyRegistry + .sign({ provider: 'custom', body: BENCH_WEBHOOK_BODY, timestamp: Number(BENCH_WEBHOOK_TS) }) + .signature; + const benchWebhookSignatureInvalid = benchKeyRegistry + .sign({ provider: 'custom', body: BENCH_WEBHOOK_BODY_INVALID, timestamp: Number(BENCH_WEBHOOK_TS) }) + .signature; + + const resolveWebhookBody = (body: unknown): string => { + if (typeof body === 'string') return body; + if (body && typeof body === 'object' && Object.keys(body as Record).length > 0) { + return JSON.stringify(body); + } + return BENCH_WEBHOOK_BODY; + }; + + api.post('/webhook/verify', (req, res) => { + const signature = (req.headers['x-signature'] as string) || benchWebhookSignature; + const timestamp = (req.headers['x-timestamp'] as string) || BENCH_WEBHOOK_TS; + const ok = benchKeyRegistry.verify({ + signature, + timestamp, + body: resolveWebhookBody(req.body), + provider: 'custom', + }); + if (ok.isValid) { + res.json({ verified: true }); + } else { + res.status(401).json({ verified: false }); + } + }); + + api.post('/webhook/verify-invalid', (_req, res) => { + const ok = benchKeyRegistry.verify({ + signature: benchWebhookSignatureInvalid, + timestamp: BENCH_WEBHOOK_TS, + body: BENCH_WEBHOOK_BODY, + provider: 'custom', + }); + if (ok.isValid) { + res.json({ verified: true }); + } else { + res.status(401).json({ verified: false }); + } + }); + app.use('/api/v1', api); return app; } diff --git a/backend/src/tests/benchmarks/endpoints.ts b/backend/src/tests/benchmarks/endpoints.ts index 49da1f10..e13171a0 100644 --- a/backend/src/tests/benchmarks/endpoints.ts +++ b/backend/src/tests/benchmarks/endpoints.ts @@ -5,7 +5,7 @@ export interface BenchmarkEndpoint { name: string; - method: 'GET' | 'POST'; + method: 'GET' | 'POST' | 'OPTIONS'; path: string; body?: string; headers?: Record; @@ -53,6 +53,63 @@ export const BENCHMARK_ENDPOINTS: BenchmarkEndpoint[] = [ }), }, { name: 'circuit_breaker', method: 'GET', path: '/api/v1/circuit-breaker' }, + { name: 'circuit_breaker_rejected', method: 'GET', path: '/api/v1/circuit-breaker/rejected' }, + { + name: 'cache_plain', + method: 'GET', + path: '/api/v1/cache/plain', + }, + { + name: 'cache_header_only', + method: 'GET', + path: '/api/v1/cache/header', + }, + { + name: 'cache_memory_hit', + method: 'GET', + path: '/api/v1/cache/memory', + }, + { + name: 'cache_etag_304', + method: 'GET', + path: '/api/v1/cache/etag-304', + headers: { 'if-none-match': '*' }, + }, + { + name: 'cors_allowed', + method: 'GET', + path: '/api/v1/cors/allowed', + headers: { origin: 'https://app.example.com' }, + }, + { + name: 'cors_wildcard', + method: 'GET', + path: '/api/v1/cors/allowed', + headers: { origin: 'https://app.tenant.example.com' }, + }, + { + name: 'cors_preflight', + method: 'OPTIONS', + path: '/api/v1/cors/allowed', + headers: { + origin: 'https://app.example.com', + 'access-control-request-method': 'GET', + }, + }, + { + name: 'webhook_verify_valid', + method: 'POST', + path: '/api/v1/webhook/verify', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event: 'bench.webhook', data: { id: 'bench_w_1' } }), + }, + { + name: 'webhook_verify_invalid', + method: 'POST', + path: '/api/v1/webhook/verify-invalid', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event: 'bench.webhook', data: { id: 'bench_w_2' } }), + }, ]; export const DEFAULT_BENCHMARK_OPTIONS = {