diff --git a/README.md b/README.md index d3c5c278..6ec607c6 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,10 @@ Clients can bypass the cache by sending a `Cache-Control: no-cache` request head | Endpoint | Cache key format | TTL | |-----------------------------------------|------------------------------------------------------------|----------| | `GET /api/marketplace` | `marketplace::` | 15s | -| `GET /api/investor/locks` | `investor:locks:::` | 15s | -| `GET /api/investor/locks/:invoiceId` | `investor:lock::::` | 15s | +| `GET /api/investor/locks` | `investor:locks::sha256():?` | 15s | +| `GET /api/investor/locks/:invoiceId` | `investor:lock::sha256()::sha256()` | 15s | -For investor-lock responses, `` is `admin:` for tenant-wide admin or owner reads, or `funder:` for non-admin investor reads. This prevents one authenticated principal from receiving another principal's cached lock response when the URL is otherwise identical. +For investor-lock responses, `` is `admin:` for tenant-wide admin or owner reads, or `funder:` for non-admin investor reads. The cache key hashes principal scope and `funderAddress` query values, and normalizes query parameter ordering. This prevents one authenticated principal from receiving another principal's cached lock response when the URL is otherwise identical without storing raw funder addresses in cache keys. ### Tenant isolation diff --git a/src/logger.js b/src/logger.js index 8fb718bb..b8bba9f2 100644 --- a/src/logger.js +++ b/src/logger.js @@ -9,7 +9,7 @@ * - Standardized log levels * - Request correlation via request IDs * - Automatic enrichment from the AsyncLocalStorage request context - * (requestId, correlationId, tenantId, userId) — no manual threading needed. + * (requestId, correlationId, tenantId, userId) with no manual threading. * * @module logger */ @@ -51,57 +51,37 @@ const _base = pino( ); /** - * Build a merged bindings object from the ambient context plus any - * caller-supplied overrides. Explicit values always win. - * - * @param {Record} [overrides] - Per-call bindings. - * @returns {Record} Merged bindings. - */ -function _mergeContext(overrides) { - const ctx = getContext(); - // Ambient context first so caller overrides take precedence. - return Object.keys(ctx).length === 0 && !overrides - ? {} - : { ...ctx, ...overrides }; -} - -/** - * Thin proxy that enriches every log call with the ambient request context. - * Explicit per-call fields passed to `logger.info({ … }, msg)` override the - * ambient values for that call only. + * Logger instance with stable own level methods. Keeping the wrappers as + * assignable properties lets Jest spy on `logger.warn` without recursing back + * through the wrapped Pino method. * * @type {import('pino').Logger} */ -const logger = new Proxy(_base, { - get(target, prop) { - const LEVEL_METHODS = new Set(['trace', 'debug', 'info', 'warn', 'error', 'fatal']); - if (typeof prop === 'string' && LEVEL_METHODS.has(prop)) { - return function enrichedLog(objOrMsg, ...rest) { - const ctx = getContext(); - const hasCtx = Object.keys(ctx).length > 0; - - if (!hasCtx) { - // No ambient context — call through unchanged (background jobs). - return target[prop](objOrMsg, ...rest); - } - - if (typeof objOrMsg === 'string') { - // Signature: logger.info('message') - return target[prop]({ ...ctx }, objOrMsg, ...rest); - } - - if (objOrMsg && typeof objOrMsg === 'object') { - // Signature: logger.info({ key: val }, 'message') - // Explicit fields override ambient. - return target[prop]({ ...ctx, ...objOrMsg }, ...rest); - } - - return target[prop](objOrMsg, ...rest); - }; +const logger = _base; +const LEVEL_METHODS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; + +for (const level of LEVEL_METHODS) { + const write = _base[level].bind(_base); + + logger[level] = function enrichedLog(objOrMsg, ...rest) { + const ctx = getContext(); + const hasCtx = Object.keys(ctx).length > 0; + + if (!hasCtx) { + return write(objOrMsg, ...rest); } - return target[prop]; - }, -}); + + if (typeof objOrMsg === 'string') { + return write({ ...ctx }, objOrMsg, ...rest); + } + + if (objOrMsg && typeof objOrMsg === 'object') { + return write({ ...ctx, ...objOrMsg }, ...rest); + } + + return write(objOrMsg, ...rest); + }; +} /** * Create a per-request child logger bound only with safe correlation fields. diff --git a/src/metrics.js b/src/metrics.js index 7fb1b44e..5f6c4843 100644 --- a/src/metrics.js +++ b/src/metrics.js @@ -46,6 +46,9 @@ try { * @implements {import('prom-client').Registry} */ class RegistryShim { + /** + * Creates an empty in-memory registry shim. + */ constructor() { this.contentType = 'text/plain'; this._items = new Map(); @@ -85,6 +88,8 @@ try { */ class LabelledMetricShim { /** + * Creates a labelled metric shim and registers it. + * * @param {object} [config] * @param {string} [config.name] * @param {string[]} [config.labelNames] @@ -103,6 +108,8 @@ try { } } /** + * Normalizes positional or object label values. + * * @param {unknown[]|object} args - Raw label arguments. * @returns {Record} */ @@ -122,6 +129,8 @@ try { return labels; } /** + * Builds the internal hash key for a label set. + * * @param {Record} labels - Normalized label map. * @returns {string} */ @@ -129,6 +138,8 @@ try { return JSON.stringify(labels); } /** + * Finds or initializes the storage entry for labels. + * * @param {Record} labels - Normalized label map. * @returns {{ labels: Record, value: number }} */ @@ -143,6 +154,8 @@ try { return this.hashMap[key]; } /** + * Returns a metric child facade bound to labels. + * * @param {...unknown} values - Positional or object labels. * @returns {object} */ @@ -156,6 +169,8 @@ try { }; } /** + * Reads the current value for a label set. + * * @param {Record} [labels={}] - Label set to inspect. * @returns {number} */ @@ -163,7 +178,11 @@ try { const entry = this.hashMap[this._hashKey(labels)]; return entry ? entry.value : 0; } - /** @returns {void} */ + /** + * Resets all stored label values. + * + * @returns {void} + */ reset() { this.hashMap = {}; } @@ -330,7 +349,7 @@ const JOB_TYPE_ENUM = Object.freeze(['maturity_reminder', 'webhook_replay', 'unk * Bounded enum of allowed `outcome` label values for webhook replay metrics. * @readonly */ -const WEBHOOK_REPLAY_OUTCOME_ENUM = Object.freeze([ +const _WEBHOOK_REPLAY_OUTCOME_ENUM = Object.freeze([ 'success', 'failure', 'not_found', @@ -498,6 +517,28 @@ function normalizeJobType(raw) { return JOB_TYPE_ENUM.includes(str) ? str : 'unknown'; } +/** + * Maps a raw reminder delivery error to a bounded Prometheus label value. + * + * @param {unknown} err - Raw error object, code, or message. + * @returns {string} Bounded label value from {@link REMINDER_REASON_ENUM}. + */ +function normalizeReminderReason(err) { + const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''; + const message = err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err || ''); + const value = `${code} ${message}`.toLowerCase(); + + let reason = 'unknown'; + if (value.includes('timeout') || value.includes('timed out') || value.includes('etimedout')) { + reason = 'smtp_timeout'; + } else if (value.includes('template')) { + reason = 'template_error'; + } else if (value.includes('reject') || value.includes('smtp') || value.includes('recipient')) { + reason = 'smtp_reject'; + } + return REMINDER_REASON_ENUM.includes(reason) ? reason : 'unknown'; +} + /** * Maps a raw Soroban RPC method identifier to a bounded metric label value. * @@ -778,6 +819,50 @@ const escrowReconciliationDriftAlertsTotal = new client.Counter({ registers: [registry], }); +/** + * Counter: Maturity reminder email delivery attempts. + * @type {import('prom-client').Counter} + */ +const maturityReminderDeliveryAttemptsTotal = new client.Counter({ + name: 'maturity_reminder_delivery_attempts_total', + help: 'Total number of maturity reminder delivery attempts', + labelNames: ['reason', 'job_type'], + registers: [registry], +}); + +/** + * Counter: Successful maturity reminder deliveries. + * @type {import('prom-client').Counter} + */ +const maturityReminderDeliverySuccessTotal = new client.Counter({ + name: 'maturity_reminder_delivery_success_total', + help: 'Total number of successful maturity reminder deliveries', + labelNames: ['job_type'], + registers: [registry], +}); + +/** + * Counter: Maturity reminder dead-letter writes. + * @type {import('prom-client').Counter} + */ +const maturityReminderDeadLetterTotal = new client.Counter({ + name: 'maturity_reminder_dead_letter_total', + help: 'Total number of maturity reminder jobs moved to the dead-letter path', + labelNames: ['reason', 'job_type'], + registers: [registry], +}); + +/** + * Counter: Contract WASM version mismatch alerts. + * @type {import('prom-client').Counter} + */ +const contractWasmVersionMismatchAlertsTotal = new client.Counter({ + name: 'contract_wasm_version_mismatch_alerts_total', + help: 'Total number of contract WASM version mismatch alerts', + labelNames: ['status'], + registers: [registry], +}); + /** * Counter: Failed idempotency response storage attempts after all retries exhausted. * Labelled by key prefix (first 8 chars) for operational visibility without exposing full keys. @@ -801,6 +886,77 @@ const bodySizeLimitRejectionsTotal = new client.Counter({ registers: [registry], }); +/** + * Counter: Cache middleware/store errors. + * @type {import('prom-client').Counter} + */ +const cacheStoreErrorsTotal = new client.Counter({ + name: 'cache_store_errors_total', + help: 'Total number of cache store errors handled fail-open', + registers: [registry], +}); + +/** + * Counter: Redis cache fail-open events. + * @type {import('prom-client').Counter} + */ +const redisCacheFailOpenTotal = new client.Counter({ + name: 'redis_cache_fail_open_total', + help: 'Total number of Redis cache fail-open events', + registers: [registry], +}); + +/** + * Counter: Footprint cache hits. + * @type {import('prom-client').Counter} + */ +const footprintCacheHitsTotal = new client.Counter({ + name: 'footprint_cache_hits_total', + help: 'Total number of footprint cache hits', + registers: [registry], +}); + +/** + * Counter: Footprint cache misses. + * @type {import('prom-client').Counter} + */ +const footprintCacheMissesTotal = new client.Counter({ + name: 'footprint_cache_misses_total', + help: 'Total number of footprint cache misses', + registers: [registry], +}); + +/** + * Counter: Footprint cache evictions. + * @type {import('prom-client').Counter} + */ +const footprintCacheEvictionsTotal = new client.Counter({ + name: 'footprint_cache_evictions_total', + help: 'Total number of footprint cache evictions', + registers: [registry], +}); + +/** + * Counter: Soroban circuit breaker state transitions. + * @type {import('prom-client').Counter} + */ +const sorobanCircuitBreakerStateTransitionsTotal = new client.Counter({ + name: 'soroban_circuit_breaker_state_transitions_total', + help: 'Total number of Soroban circuit breaker state transitions', + labelNames: ['breaker_name', 'from_state', 'to_state'], + registers: [registry], +}); + +/** + * Gauge: Overall service readiness state. + * @type {import('prom-client').Gauge} + */ +const readinessGauge = new client.Gauge({ + name: 'readiness_state', + help: 'Overall service readiness state: 1 ready, 0.5 degraded, 0 not ready', + registers: [registry], +}); + /** * Counter: Webhook dead-letter replay attempts, labelled by bounded `outcome`. * @type {import('prom-client').Counter} @@ -844,65 +1000,46 @@ const sorobanRpcRetryCausesTotal = new client.Counter({ */ function getRegistry() { return registry; - * Registers a job queue for metric collection. - * @param {object} queue - Queue instance with a getStats() method. - * @returns {void} - */ -function registerJobQueue(queue) { - registeredJobQueues.add(queue); -} - -/** - * Registers a worker for metric collection. - * @param {object} worker - Worker instance with a getStats() method. - * @returns {void} - */ -function registerWorker(worker) { - registeredWorkers.add(worker); } module.exports = { - registry, - getRegistry, - metricsAuth, - metricsHandler, - registerJobQueue, - registerWorker, - refreshMetrics, - resetMetricsForTests, + bodySizeLimitRejectionsTotal, + cacheStoreErrorsTotal, + contractWasmVersionMismatchAlertsTotal, + escrowIndexerCycleFailuresTotal, escrowIndexerEventsProcessedTotal, escrowIndexerEventsSkippedTotal, - escrowIndexerCycleFailuresTotal, escrowIndexerLastCursorAdvanceTimestampSeconds, + escrowReconciliationDriftAlertsTotal, + escrowReconciliationDriftMagnitudeGauge, + escrowReconciliationMismatchedInvoicesGauge, escrowReconciliationMismatches, - maturityReminderDeliveryAttemptsTotal, - maturityReminderDeliverySuccessTotal, + footprintCacheEvictionsTotal, + footprintCacheHitsTotal, + footprintCacheMissesTotal, + getRegistry, + idempotencyStorageFailureTotal, maturityReminderDeadLetterTotal, - contractWasmVersionMismatchAlertsTotal, - readinessGauge, - escrowIndexerLastCursorAdvanceTimestampSeconds, - escrowIndexerEventsProcessedTotal, - escrowIndexerEventsSkippedTotal, - escrowIndexerCycleFailuresTotal, - escrowReconciliationMismatches, - escrowReconciliationMismatchedInvoicesGauge, - escrowReconciliationDriftMagnitudeGauge, - escrowReconciliationDriftAlertsTotal, maturityReminderDeliveryAttemptsTotal, maturityReminderDeliverySuccessTotal, - maturityReminderDeadLetterTotal, - footprintCacheHitsTotal, - footprintCacheMissesTotal, - footprintCacheEvictionsTotal, - sorobanCircuitBreakerStateTransitionsTotal, - sorobanRpcCallDurationSeconds, - sorobanRpcRetryCausesTotal, - webhookReplayTotal, - bodySizeLimitRejectionsTotal, + metricsAuth, + metricsHandler, normalizeJobType, + normalizeReminderReason, + normalizeSorobanRetryCause, normalizeSorobanRpcMethod, normalizeSorobanRpcOutcome, - normalizeSorobanRetryCause, + readinessGauge, + redisCacheFailOpenTotal, + refreshMetrics, + registerJobQueue, + registerWorker, + registry, + resetMetricsForTests, + sorobanCircuitBreakerStateTransitionsTotal, + sorobanRpcCallDurationSeconds, + sorobanRpcRetryCausesTotal, startMetricsRefresh, stopMetricsRefresh, + webhookReplayTotal, }; diff --git a/src/middleware/cache.js b/src/middleware/cache.js index 7271b4ef..bfe58ff8 100644 --- a/src/middleware/cache.js +++ b/src/middleware/cache.js @@ -24,10 +24,81 @@ * @module middleware/cache */ +const crypto = require('crypto'); const logger = require('../logger'); const { cacheStoreErrorsTotal } = require('../metrics'); const { getInvestorLockPrincipalScope } = require('../utils/investorLockScope'); +const SENSITIVE_QUERY_PARAMS = new Set(['funderAddress']); + +/** + * Hashes cache-key components that can contain wallet or funder identifiers. + * + * @param {unknown} value - Sensitive cache key component. + * @returns {string} Stable SHA-256 cache-key segment. + */ +function hashCacheComponent(value) { + return crypto + .createHash('sha256') + .update(String(value || ''), 'utf8') + .digest('hex'); +} + +/** + * Converts an Express query value into deterministic key segments. + * + * @param {string} name - Query parameter name. + * @param {unknown} value - Query parameter value. + * @returns {string[]} Encoded query segments. + */ +function encodeQueryValue(name, value) { + const values = Array.isArray(value) ? value : [value]; + return values + .map((entry) => { + const safeValue = SENSITIVE_QUERY_PARAMS.has(name) + ? `sha256:${hashCacheComponent(entry)}` + : String(entry); + return `${encodeURIComponent(name)}=${encodeURIComponent(safeValue)}`; + }) + .sort(); +} + +/** + * Builds a deterministic path+query segment for investor lock cache keys. + * + * Sensitive query values are hashed, and query parameters are sorted so + * equivalent requests produce one cache key regardless of query-string order. + * + * @param {import('express').Request} req - The Express request. + * @returns {string} Stable request target key. + */ +function makeInvestorRequestTargetKey(req) { + const originalUrl = req.originalUrl || ''; + const path = req.path || originalUrl.split('?')[0] || ''; + const query = req.query && typeof req.query === 'object' ? req.query : {}; + const queryKeys = Object.keys(query).sort(); + + if (queryKeys.length === 0) { + return path; + } + + const queryString = queryKeys + .flatMap((name) => encodeQueryValue(name, query[name])) + .join('&'); + + return `${path}?${queryString}`; +} + +/** + * Builds a hashed principal scope for investor-lock cache isolation. + * + * @param {import('express').Request} req - The Express request. + * @returns {string} Principal scope safe for cache keys. + */ +function makeInvestorPrincipalScopeKey(req) { + return `sha256:${hashCacheComponent(getInvestorLockPrincipalScope(req))}`; +} + /** * Creates an Express middleware that caches JSON responses with a TTL. * @@ -131,7 +202,7 @@ function makeMarketplaceKey(req) { */ function makeInvestorLocksKey(req) { const tenantId = req.tenantId || 'unknown'; - return 'investor:locks:' + tenantId + ':' + getInvestorLockPrincipalScope(req) + ':' + req.originalUrl; + return 'investor:locks:' + tenantId + ':' + makeInvestorPrincipalScopeKey(req) + ':' + makeInvestorRequestTargetKey(req); } /** @@ -143,7 +214,7 @@ function makeInvestorLocksKey(req) { */ function makeInvestorLockKey(req) { const tenantId = req.tenantId || 'unknown'; - return 'investor:lock:' + tenantId + ':' + getInvestorLockPrincipalScope(req) + ':' + req.params.invoiceId + ':' + req.query.funderAddress; + return 'investor:lock:' + tenantId + ':' + makeInvestorPrincipalScopeKey(req) + ':' + req.params.invoiceId + ':sha256:' + hashCacheComponent(req.query.funderAddress); } /** @@ -175,4 +246,5 @@ module.exports = { makeMarketplaceKey, makeInvestorLocksKey, makeInvestorLockKey, + hashCacheComponent, }; diff --git a/src/middleware/cache.test.js b/src/middleware/cache.test.js index bfdfbcfb..d8ff342c 100644 --- a/src/middleware/cache.test.js +++ b/src/middleware/cache.test.js @@ -1,4 +1,11 @@ -const { cacheResponse, invalidatePrefix, makeMarketplaceKey, makeInvestorLocksKey, makeInvestorLockKey } = require('./cache'); +const { + cacheResponse, + hashCacheComponent, + invalidatePrefix, + makeMarketplaceKey, + makeInvestorLocksKey, + makeInvestorLockKey, +} = require('./cache'); const { MemoryCacheStore, getSharedStore } = require('../services/cacheStore'); const logger = require('../logger'); const { cacheStoreErrorsTotal } = require('../metrics'); @@ -93,6 +100,53 @@ describe('cacheResponse', () => { }); }); + it('keeps single investor-lock cached responses isolated by funder for the same invoice', () => { + const middleware = cacheResponse({ ttl: 5000, store, keyFn: makeInvestorLockKey }); + const funderAReq = { + tenantId: 'tenant-a', + params: { invoiceId: 'inv_123' }, + query: { funderAddress: 'GAAA' }, + user: { funderAddress: 'GAAA' }, + }; + const funderARes = createMockRes(); + let funderANextCalled = false; + + middleware(funderAReq, funderARes, () => { + funderANextCalled = true; + funderARes.json({ invoiceId: 'inv_123', funderAddress: 'GAAA' }); + }); + + const funderBReq = { + tenantId: 'tenant-a', + params: { invoiceId: 'inv_123' }, + query: { funderAddress: 'GBBB' }, + user: { funderAddress: 'GBBB' }, + }; + const funderBRes = createMockRes(); + let funderBNextCalled = false; + + middleware(funderBReq, funderBRes, () => { + funderBNextCalled = true; + funderBRes.json({ invoiceId: 'inv_123', funderAddress: 'GBBB' }); + }); + + const funderBRepeatRes = createMockRes(); + let funderBRepeatNextCalled = false; + + middleware(funderBReq, funderBRepeatRes, () => { + funderBRepeatNextCalled = true; + }); + + expect(funderANextCalled).toBe(true); + expect(funderARes.headers['X-Cache']).toBe('MISS'); + expect(funderBNextCalled).toBe(true); + expect(funderBRes.headers['X-Cache']).toBe('MISS'); + expect(funderBRes.body).toEqual({ invoiceId: 'inv_123', funderAddress: 'GBBB' }); + expect(funderBRepeatNextCalled).toBe(false); + expect(funderBRepeatRes.headers['X-Cache']).toBe('HIT'); + expect(funderBRepeatRes.body).toEqual({ invoiceId: 'inv_123', funderAddress: 'GBBB' }); + }); + it('falls through to handler when cache store get throws', (done) => { const brokenStore = { get() { throw new Error('store broken'); }, @@ -315,20 +369,109 @@ describe('makeMarketplaceKey', () => { }); describe('makeInvestorLocksKey', () => { - it('includes tenantId and originalUrl in the cache key', () => { - const req = { tenantId: 'tenant-beta', originalUrl: '/api/investor/locks?funderAddress=GABC' }; - expect(makeInvestorLocksKey(req)).toBe('investor:locks:tenant-beta:/api/investor/locks?funderAddress=GABC'); + it('includes tenantId, hashed principal scope, and hashed funder query in the cache key', () => { + const req = { + tenantId: 'tenant-beta', + path: '/api/investor/locks', + originalUrl: '/api/investor/locks?funderAddress=GABC', + query: { funderAddress: 'GABC' }, + user: { funderAddress: 'GABC' }, + }; + + const key = makeInvestorLocksKey(req); + + expect(key).toBe( + `investor:locks:tenant-beta:sha256:${hashCacheComponent('funder:GABC')}` + + `:/api/investor/locks?funderAddress=sha256%3A${hashCacheComponent('GABC')}` + ); + expect(key).not.toContain('GABC'); + }); + + it('uses a stable key for the same funder and pagination request', () => { + const req = { + tenantId: 'tenant-beta', + path: '/api/investor/locks', + query: { page: '2', limit: '10', funderAddress: 'GABC' }, + user: { funderAddress: 'GABC' }, + }; + + expect(makeInvestorLocksKey(req)).toBe(makeInvestorLocksKey(req)); + }); + + it('separates list keys by tenant, pagination, and bound funder', () => { + const base = { + path: '/api/investor/locks', + query: { page: '1', limit: '10', funderAddress: 'GABC' }, + user: { funderAddress: 'GABC' }, + }; + const sameTenantNextPage = { ...base, tenantId: 'tenant-a', query: { ...base.query, page: '2' } }; + const otherTenant = { ...base, tenantId: 'tenant-b' }; + const otherFunder = { + ...base, + tenantId: 'tenant-a', + query: { ...base.query, funderAddress: 'GDEF' }, + user: { funderAddress: 'GDEF' }, + }; + + const key = makeInvestorLocksKey({ ...base, tenantId: 'tenant-a' }); + + expect(key).not.toBe(makeInvestorLocksKey(sameTenantNextPage)); + expect(key).not.toBe(makeInvestorLocksKey(otherTenant)); + expect(key).not.toBe(makeInvestorLocksKey(otherFunder)); + }); + + it('builds a deterministic key when funderAddress is omitted', () => { + const req = { + tenantId: 'tenant-beta', + path: '/api/investor/locks', + query: { page: '1', limit: '20' }, + user: { role: 'admin' }, + }; + + expect(makeInvestorLocksKey(req)).toBe( + `investor:locks:tenant-beta:sha256:${hashCacheComponent('admin:admin')}` + + ':/api/investor/locks?limit=20&page=1' + ); }); }); describe('makeInvestorLockKey', () => { - it('includes tenantId, invoiceId, and funderAddress', () => { + it('includes tenantId, invoiceId, hashed principal scope, and hashed funderAddress', () => { const req = { tenantId: 'tenant-gamma', params: { invoiceId: 'inv_123' }, query: { funderAddress: 'GXXX' }, + user: { funderAddress: 'GXXX' }, }; - expect(makeInvestorLockKey(req)).toBe('investor:lock:tenant-gamma:inv_123:GXXX'); + + const key = makeInvestorLockKey(req); + + expect(key).toBe( + `investor:lock:tenant-gamma:sha256:${hashCacheComponent('funder:GXXX')}:inv_123:sha256:${hashCacheComponent('GXXX')}` + ); + expect(key).not.toContain('GXXX'); + }); + + it('separates single-lock keys for same invoice across different funders', () => { + const base = { + tenantId: 'tenant-gamma', + params: { invoiceId: 'inv_123' }, + }; + const funderA = { ...base, query: { funderAddress: 'GAAA' }, user: { funderAddress: 'GAAA' } }; + const funderB = { ...base, query: { funderAddress: 'GBBB' }, user: { funderAddress: 'GBBB' } }; + + expect(makeInvestorLockKey(funderA)).not.toBe(makeInvestorLockKey(funderB)); + }); + + it('uses the same single-lock key for identical repeated requests', () => { + const req = { + tenantId: 'tenant-gamma', + params: { invoiceId: 'inv_123' }, + query: { funderAddress: 'GAAA' }, + user: { funderAddress: 'GAAA' }, + }; + + expect(makeInvestorLockKey(req)).toBe(makeInvestorLockKey(req)); }); });