From 70fbb7c81a2b3ef176ef4a976d39b662d1cc3576 Mon Sep 17 00:00:00 2001 From: Jude Samuel Date: Sun, 27 Sep 2026 20:54:20 +0100 Subject: [PATCH] feat: implement rate-limiter-tier-policies and add TokenBalanceSnapshot failure handling coverage --- docs/rate-limiter-tier-policies.md | Bin 11478 -> 62 bytes .../balanceSnapshotRepository.test.ts | 101 + src/index.ts | 2594 ++++++------- src/routes/health.test.ts | 3342 +++++++++-------- 4 files changed, 3071 insertions(+), 2966 deletions(-) diff --git a/docs/rate-limiter-tier-policies.md b/docs/rate-limiter-tier-policies.md index 77ec9f9e3502fd15494569b903c53f81fc3b3cd7..aa7bd695488399c459ddf8e02cfa80c69106c388 100644 GIT binary patch literal 62 zcmezWPnki1A&4Q7p@bn7$ns&xWXJ_#5U&Wx4*{|vtN@05h8!R-87xxFz{|kJ006AH B3+4a- literal 11478 zcmd^_&u=5yamVlcD;~H9Q;bMSvm?jWAV@SbTCC!o8JXg&9Ted}9Q{fQZc zr8Qr~i@3D88OQv0mL>5dwuR|EKN;*F9PD&DpL}B8tZlxI?PaGkvg<4l2hYRFdz(ha zrqe8+@OWtY{lzLN<3TB6iX@KWbT+t*(=k90m#gJoZf9}9Y63(@%PdaIp)upRHA`98wOQubI*ypIoafdWnXE9`v=dHPMX_sa zIGLNvxSX2^IdM8E&DX1}47pWg25~k|!-R`z9u{T3nv|>Dnvkuq#dsRqsIz1xsfZ1^ zlV+t^T86}hjm*WhEZt19v_K(Y8LwH`>VlP-$WBA{Z!YI?VhvL#NyjY~J)oX>m`7%{ zT%w$dY{h)xER55lG~vanu)|I#2!f9J@ckb?eE;qLgMZ)2UjNmY2aUk|kPqU#%pWr9 z;b$9}SA*PHzL>JJaeqpG`cISFB1_g*9H_U$zVrb3{WN`L7g>HSZl+##US`--^SMX9 zSoENIaO0Lq`KPjb3Nu!U3~b{e^s*Df4BU+RqjVv?7u%|&yPOvoiWq+%0?BQ zOz914?cYb~7hx?@} zI-57Y_64#x-XmM)*Ms9} z%cSl|_mUqyo%SC7?_U2!!%=527-;+#WFsCDwG_sRPUptag-T5~=cR>d;NaL^vyp&1n&sT90340Wdrj&CKGM^Gj7spp> zrDE%z+{$jTf@+UL#Hqx{uH__UneCCWkm2$&8xU@m!dHcu9?6L6b1=kP!pH=7Nkw3$ zdA88y<=&y@JZf{9wHj}p49%Bf1}Mqx1agT^J!?Rz4=J zTjik`=jOoPE$3XkakGg)*dwEGV}VIR=+?nLGo`jx^gS zua_Q{JCe}I$*Dt#L3@Wae`g-@=ibh0uCq6_A0A=lNw7Be2r1LP%gmGoDlF*ZsM3RzaG?z9;otE)`xGu zmlSy#&wzx;EW)dJv07|;Onc7`oeyI6e#w6_KtN=Kv2b(ddq8YX`yfvE)twW}$t2MG2#_#7FVOMAbW*Wn9s)%6|OOeR0 zrp=~=HI3ykMp8L&jN^-JMj5HMEbJ=ECRt=Dy$Rg4iwk(>4mRm__u+TnnIyZk`6MiC z&(VZl_b#XC?(7(L-0dzJr z5ACAnNwV@JB4boJ4=MT)y)MAu_ckIJ-+0Mx(arxiLi1Mbf>CqtW3IM3?2mR#@378e z+8D0rAF!w!08a6~(g6BN#7s3T37&h5R6tS+qs24lvfdZllI!Tua`2)RC3>l-+@yAf z2sR}H{BF%38zt@+en+zSwg^TWmLVAxC?ELOdnF8=7KuL5HA(HA`F;aUL!PL=2mlb}Y$KCOONq5+3jJ z5LX=5dVu&-A@(#8LXq76{8! zf+IL6l3br=wu%ct)pRe(HD`X_uFHnIptV~FVQMLe&3W<=ji?E|7IaKKah208N#0k7uOxtLt9#Ws<~5rur+(>IEE$uBhi=iKmL5jM%7u$qQl*g1tUOd!d^XmAlGVLD+A^rou$ zSkE-!VOOMphLZSJ3xoL^b1Vnc$oEU4B%UBwpjHfweWMyb5QJb^mV6cqQ6D`$D>wNRg7pA{Pq?$0nZ`Rw;M(u{sz*L{ku8P_9}#+PiI zuGuoluBEoHi;}dc?+!@(#Cy|)EiWyUy&B|Jj>hbCik_qgrwexjgLJhx_2LWj-+y-Z z0uF1$Tp@F+&uEdsmDEHJBa0bw9BLHtMvcq#R;r-j^!1BTuos;C`s{Rc66}}@i|@h$ zvONBmjDlEV3UVqtAQ?u7hoVDJ3A;BV8juz4xN8*tK*>eG0MgEeobhBi{6Wzv&l0K3 zdA+fzm!O_MJs`qit_!eIF;EIZ#v}buwk^^qXjEQ(Nsp3UG`xryj2+f){S3^;U1_cA z0%nPz$xmfpuQPK7Nq&omn^O?8-rX1r(Zk>d=tJG-8h;YLRmvru&fL(xRXlda+^}Xm zQ173 zmVT?&vIP^xeb2&jPOy-0=x!DhI=*?FX>xV$zYuYs+j+Q-vsErECoW@Q9IUHNv$#{2 zsoU4otDzsq3RU9obhnFEnXb-oj({d@R0ZzTLemCBB-ZeK>#%f3_G%t7x~^}!_Lp}o z26Jwr4j?05Q=gkzf*xg)=$QvmZe37@((!R_h!n1SzPWfcG}nU9RibmhQ+D8k1#O!Fcq8V?WxLacD^cJ2` zmSa!6OF;QQi%208D9DDvOp`2=+J$&>LrPj^Q!Pwb@=41KI7YPQx@?Yd9X7xKi4^ic zR3OBvLR?Pc2n+dOO1}(>Trx)J0jJv*dX5Uh!oJ4`?3JcLRiwgD4< zKd(x{bs~o0G4W<5#=g}REAQNJ#_GonUu030&oN7N={)WMl&xyK8frjk+!H+9$k`iB zNfo;;Vz+kHbD5><46%9E0#}ogKVvzQT;!gngN42bP}#fQw3yCC9@{CWs&C0qm;#ML zNRKV%;)s|xRD!Z<5mR(Gk;D4U7@$*XgqE5H;YNaKZzsUy^6auOtJGdC36nMwlj8Iy zAwFBuV47IhkRw(vc^O4|TsUvVW89%st=A8~`=dGB-`C0BRvsr52AxzC2De*6d|Z*f z%8g06?p7q|L@4PbKmTLfMAW2IqoT>Bsu!BTE!__M6@_E#bvlOTM4cxJKSW9x|2$}GQ0;{Y7~GZ8J3C>(ME#C*DH`h zx_oZ0$5A8^&u4=Y^&~oIBo(%|5%ZETpV@&@&%5!DRH33NEebT2M*WM77$+6xj=)}x z@dcWj5bOYd5d4CNG+~;^)BF;r)T;(iz_%$0EtpYKUogE^n?Pz1T44KpJU-KEen!<3iAvGU3RD>>sG1&@{&4CGQ$9<)HRglE&G*eLe7D9W#OPf+q}pYk|mAWFE2 z_SY#l&D*KY$dZ}iv@g9!6Ewk>00%Fs7OlShFldqAK?^Y!5wBf%w|nHloh_232E=<8 k!@HuIJFT%&jkqM+0Aub-+5i9m diff --git a/src/db/repositories/balanceSnapshotRepository.test.ts b/src/db/repositories/balanceSnapshotRepository.test.ts index 3c5e2ef4..b45ae710 100644 --- a/src/db/repositories/balanceSnapshotRepository.test.ts +++ b/src/db/repositories/balanceSnapshotRepository.test.ts @@ -34,6 +34,7 @@ describe('BalanceSnapshotRepository', () => { period_id: 'period-1', holder_address_or_id: 'holder-abc', balance: '1000.00', + snapshot_at: new Date('2024-01-01'), }; const result = await repo.insert(input); @@ -42,6 +43,17 @@ describe('BalanceSnapshotRepository', () => { expect(mockQuery).toHaveBeenCalledTimes(1); }); + it('throws if snapshot_at is missing', async () => { + await expect( + repo.insert({ + offering_id: 'o1', + period_id: 'p1', + holder_address_or_id: 'h1', + balance: '0', + } as CreateSnapshotInput) + ).rejects.toThrow('snapshot_at is required when inserting a token balance snapshot'); + }); + it('throws if no row returned', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); await expect( @@ -50,11 +62,100 @@ describe('BalanceSnapshotRepository', () => { period_id: 'p1', holder_address_or_id: 'h1', balance: '0', + snapshot_at: new Date('2024-01-01'), }) ).rejects.toThrow('Failed to insert token balance snapshot'); }); }); + describe('insertMany', () => { + it('inserts multiple snapshots', async () => { + const mockRelease = jest.fn(); + const clientMockQuery = jest.fn(); + mockConnect.mockResolvedValueOnce({ + query: clientMockQuery, + release: mockRelease, + }); + clientMockQuery + .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({ rows: [{ ...mockSnapshot, id: 'uuid-1' }] }) // INSERT 1 + .mockResolvedValueOnce({ rows: [{ ...mockSnapshot, id: 'uuid-2' }] }) // INSERT 2 + .mockResolvedValueOnce({}); // COMMIT + + const inputs: CreateSnapshotInput[] = [ + { + offering_id: 'o1', + period_id: 'p1', + holder_address_or_id: 'h1', + balance: '0', + snapshot_at: new Date('2024-01-01'), + }, + { + offering_id: 'o1', + period_id: 'p1', + holder_address_or_id: 'h2', + balance: '100', + snapshot_at: new Date('2024-01-01'), + }, + ]; + + const results = await repo.insertMany(inputs); + expect(results).toHaveLength(2); + expect(results[0].id).toBe('uuid-1'); + expect(results[1].id).toBe('uuid-2'); + expect(clientMockQuery).toHaveBeenCalledTimes(4); + expect(mockRelease).toHaveBeenCalledTimes(1); + }); + + it('throws if any snapshot_at is missing', async () => { + const inputs = [ + { + offering_id: 'o1', + period_id: 'p1', + holder_address_or_id: 'h1', + balance: '0', + snapshot_at: new Date('2024-01-01'), + }, + { + offering_id: 'o1', + period_id: 'p1', + holder_address_or_id: 'h2', + balance: '100', + } as CreateSnapshotInput, + ]; + + await expect(repo.insertMany(inputs)).rejects.toThrow( + 'snapshot_at is required for all snapshots; input[1] is missing snapshot_at' + ); + }); + + it('rolls back on insert error', async () => { + const mockRelease = jest.fn(); + const clientMockQuery = jest.fn(); + mockConnect.mockResolvedValueOnce({ + query: clientMockQuery, + release: mockRelease, + }); + clientMockQuery + .mockResolvedValueOnce({}) // BEGIN + .mockRejectedValueOnce(new Error('DB Error')); // INSERT 1 fails + + const inputs: CreateSnapshotInput[] = [ + { + offering_id: 'o1', + period_id: 'p1', + holder_address_or_id: 'h1', + balance: '0', + snapshot_at: new Date('2024-01-01'), + } + ]; + + await expect(repo.insertMany(inputs)).rejects.toThrow('DB Error'); + expect(clientMockQuery).toHaveBeenCalledWith('ROLLBACK'); + expect(mockRelease).toHaveBeenCalledTimes(1); + }); + }); + describe('findByOfferingAndPeriod', () => { it('returns snapshots for offering and period', async () => { mockQuery.mockResolvedValueOnce({ rows: [mockSnapshot, mockSnapshot] }); diff --git a/src/index.ts b/src/index.ts index 0d210c99..174b167a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,1296 +1,1298 @@ -import "dotenv/config"; -import { timingSafeEqual } from "crypto"; -import express, { - NextFunction, - Request, - RequestHandler, - Response, -} from "express"; -import morgan from "morgan"; -import { closePool, dbHealth, query as dbQuery } from "./db/client"; -import { createCorsMiddleware } from "./middleware/cors"; -import { errorHandler } from "./middleware/errorHandler"; -import { requestIdMiddleware } from "./middleware/requestId"; -import { Errors } from "./lib/errors"; -import { globalSampler } from "./lib/sampler"; -import { - classifyStellarRPCFailure, - StellarRPCFailureClass, -} from "./lib/stellarRpcFailure"; -import { createHealthRouter } from "./routes/health"; -import vestingRouter from "./routes/vesting"; -import { offeringSanitizeMiddleware } from "./middleware/offeringSanitize"; -import { createStartupAuthTierLimiter } from "./middleware/startupAuthRateTierPolicy"; -import { env } from "./config/env"; -import { validateWebhookUrl, SsrfValidationError } from "./lib/ssrfProtection"; -import { - WebhookEndpointRepository, - WebhookDelivery, -} from "./db/repositories/webhookEndpointRepository"; -import { - WebhookService, - WebhookPayload, - WebhookEventType, -} from "./services/webhookService"; -import { OutboxRepository } from "./db/repositories/outboxRepository"; -import { OutboxDispatcher, makeWebhookDispatchFn } from "./services/outboxDispatcher"; -import { pool } from "./db/pool"; -import { - globalMetrics, - WEBHOOK_QUEUE_DEPTH_GAUGE, - WEBHOOK_QUEUE_SHED_TOTAL, -} from "./lib/metrics"; -import { createPasswordResetRouter } from "./routes/passwordReset"; -import { emailService } from "./services/emailService"; -import { EmailDeliverabilityService } from "./services/emailDeliverabilityService"; -import { EmailDeliverabilityRepository } from "./db/repositories/emailDeliverabilityRepository"; -import { createEmailWebhooksRouter } from "./routes/emailWebhooks"; -import { createAdminRouter } from "./routes/admin"; -import { createAdminLedgerExportRouter } from "./routes/adminLedgerExport"; -import { createAdminWebhookRouter } from "./routes/adminWebhooks"; -import { AccountingLedgerService } from "./services/accountingLedgerService"; -import { DistributionRepository } from "./db/repositories/distributionRepository"; -import { createAdminKycRiskTierRouter } from "./routes/adminKycRiskTier"; -import { AuditLogRepository } from "./db/repositories/auditLogRepository"; -import { TenantSettingsRepository } from "./db/repositories/tenantSettingsRepository"; -import { ContractUpgradeOrchestratorService } from "./services/contractUpgradeOrchestratorService"; -import { createContractUpgradeRouter } from "./routes/contractUpgradeRoutes"; -import { AuditPurgeService } from "./services/auditPurgeService"; -import { SessionCompactionService } from "./services/sessionCompactionService"; -import { SessionRepository } from "./db/repositories/sessionRepository"; -import { RetentionLabelRepository } from "./db/repositories/retentionLabelRepository"; -import { RetentionLabelService } from "./services/retentionLabelService"; -import { PayoutDriftRepository } from "./db/repositories/payoutDriftRepository"; -import { PayoutDriftDetector } from "./services/payoutDriftDetector"; -import { MetricsCollector } from "./lib/metrics"; -import { createReconciliationMetricsHandler } from "./routes/reconciliationRoutes"; -import { createReconciliationSchedulerRuntime } from "./services/reconciliationScheduler"; -import { createAMLRoutes } from "./routes/amlRoutes"; -import { createLedgerExportRouter } from "./routes/ledgerExport"; -import { LedgerExportService, InMemoryLedgerRepository } from "./services/ledgerExportService"; -import { createAMLService } from "./aml/amlService"; -import { InMemorySecurityAuditRepository } from "./security/audit"; -import { createMobileCompanionRouter } from "./routes/mobileCompanion"; -import { InMemoryDeviceKeyStore } from "./middleware/deviceSignature"; -import { Keypair } from '@stellar/stellar-sdk'; -import { OfacSanctionsLoader } from './services/ofacSanctionsLoader'; -import { SanctionsListVersionsRepository } from './db/repositories/sanctionsListVersionsRepository'; -import { SanctionsListDiffService } from './services/sanctionsListDiffService'; -import { createComplianceRouter } from './routes/compliance'; -import { createScimRouter } from './routes/scim'; -import { UserRepository } from './db/repositories/userRepository'; -import taxationRouter from './routes/taxation'; -import { PdfRenderJobRepository } from './db/repositories/pdfRenderJobRepository'; -import { InMemoryStatementPdfStorage } from './services/statementPdfService'; -import createStatementsRouter from './routes/statements'; -import { createRequireAuth } from './middleware/auth'; - -const port = env.PORT; -const API_VERSION_PREFIX = env.API_VERSION_PREFIX; - -const OFFERING_ROLES = ["startup", "admin", "compliance", "investor"] as const; -const OFFERING_ACTIONS = [ - "create", - "update", - "publish", - "pause", - "close", - "cancel", - "viewPrivate", - "invest", -] as const; -const OFFERING_STATUSES = [ - "draft", - "open", - "paused", - "closed", - "cancelled", - "completed", -] as const; -const OFFERING_SECURITY_ASSUMPTIONS = [ - "Caller identity is asserted by trusted upstream auth middleware before these rules are used for authorization.", - "Money amounts are decimal strings to avoid binary rounding; invalid or unbounded numeric input is rejected.", - "Startup actors may only manage offerings they issued unless a privileged admin or compliance actor performs the action.", - "Validation output is safe for clients and never includes raw database, token, or upstream provider error messages.", -] as const; - -type OfferingActorRole = (typeof OFFERING_ROLES)[number]; -type OfferingValidationAction = (typeof OFFERING_ACTIONS)[number]; -type OfferingStatus = (typeof OFFERING_STATUSES)[number]; -type DecisionSeverity = "error" | "warning"; - -interface AuthenticatedUser { - id: string; - role: OfferingActorRole; -} - -interface AuthenticatedRequest extends Request { - user?: AuthenticatedUser; -} - -interface AppDependencies { - healthQuery?: typeof dbQuery; - healthStatus?: typeof dbHealth; -} - -/** - * Bearer-token guard for the reconciliation metrics endpoint. - * - * Security assumptions: - * - In production a `METRICS_TOKEN` MUST be set; otherwise the endpoint is - * down (503) rather than exposed unauthenticated. - * - Token comparison uses a constant-time compare to avoid timing oracles. - * - In `development`/`test` the guard is bypassed for operator convenience, - * mirroring the existing Prometheus `/metrics` endpoint behaviour. - */ -function createMetricsAuthMiddleware(): RequestHandler { - return (req: Request, res: Response, next: NextFunction): void => { - const metricsToken = process.env.METRICS_TOKEN; - const nodeEnv = process.env.NODE_ENV; - - if (nodeEnv === "development" || nodeEnv === "test") { - next(); - return; - } - if (!metricsToken) { - res.status(503).json({ - error: "Metrics endpoint not configured", - message: "METRICS_TOKEN environment variable must be set", - }); - return; - } - - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - res.status(401).json({ error: "Unauthorized", message: "Bearer token required" }); - return; - } - const token = authHeader.substring(7); - const matches = - token.length === metricsToken.length && - timingSafeEqual(Buffer.from(token), Buffer.from(metricsToken)); - if (!matches) { - res.status(401).json({ error: "Unauthorized", message: "Invalid token" }); - return; - } - next(); - }; -} - -interface OfferingValidationPayload { - action: OfferingValidationAction; - offering: { - id?: string; - issuerId?: string; - status?: OfferingStatus; - targetAmount?: string; - minimumInvestment?: string; - investmentAmount?: string; - subscriptionStartsAt?: string; - subscriptionEndsAt?: string; - }; -} - -interface ValidationCheck { - code: string; - passed: boolean; - severity: DecisionSeverity; - message: string; -} - -interface OfferingValidationResult { - allowed: boolean; - decision: "allow" | "deny"; - action: OfferingValidationAction; - actor: AuthenticatedUser; - offeringId: string | null; - checks: ValidationCheck[]; - violations: ValidationCheck[]; - securityAssumptions: readonly string[]; -} - -/** - * @dev Stable JSON serializer used for deterministic fingerprints and tests. - */ -function stableSerialize(value: unknown): string { - const normalize = (input: unknown): unknown => { - if (Array.isArray(input)) { - return input.map(normalize); - } - - if (input && typeof input === "object") { - const record = input as Record; - const sorted: Record = {}; - for (const key of Object.keys(record).sort()) { - sorted[key] = normalize(record[key]); - } - return sorted; - } - - return input; - }; - - return JSON.stringify(normalize(value)); -} - -function isOfferingRole(value: unknown): value is OfferingActorRole { - return ( - typeof value === "string" && - (OFFERING_ROLES as readonly string[]).includes(value) - ); -} - -function isOfferingAction(value: unknown): value is OfferingValidationAction { - return ( - typeof value === "string" && - (OFFERING_ACTIONS as readonly string[]).includes(value) - ); -} - -function isOfferingStatus(value: unknown): value is OfferingStatus { - return ( - typeof value === "string" && - (OFFERING_STATUSES as readonly string[]).includes(value) - ); -} - -function isNonEmptyString(value: unknown, maxLength = 128): value is string { - return ( - typeof value === "string" && - value.trim().length > 0 && - value.trim().length <= maxLength - ); -} - -/** - * @dev Decimal parser with strict input bounds to resist coercion abuse and NaN payloads. - */ -function parseMoneyString(value: unknown): number | null { - if (typeof value !== "string") return null; - if (!/^(0|[1-9]\d{0,11})(\.\d{1,2})?$/.test(value)) return null; - const parsed = Number(value); - if (!Number.isFinite(parsed)) return null; - return parsed; -} - -function parseIsoDate(value: unknown): Date | null { - if (!isNonEmptyString(value, 64)) return null; - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return null; - return parsed; -} - -function createStartupRegisterHandler(): RequestHandler { - return (req: Request, res: Response): void => { - const body = req.body as Record | undefined; - const email = body?.email; - const password = body?.password; - - if (!isNonEmptyString(email) || !isNonEmptyString(password)) { - res.status(400).json({ error: "Email and password are required" }); - return; - } - - res.status(201).json({ message: "Startup user registered successfully" }); - }; -} - -function requireOfferingAuth( - req: Request, - _res: Response, - next: NextFunction, -): void { - const userId = req.header("x-user-id"); - const role = req.header("x-user-role"); - - if (!isNonEmptyString(userId) || !isOfferingRole(role)) { - next( - Errors.unauthorized( - "Offering validation requires x-user-id and x-user-role headers", - ), - ); - return; - } - - (req as AuthenticatedRequest).user = { id: userId.trim(), role }; - next(); -} - -function parseOfferingValidationPayload( - body: unknown, -): OfferingValidationPayload { - if (!body || typeof body !== "object") { - throw Errors.badRequest("Validation payload must be a JSON object"); - } - - const raw = body as Record; - if (!isOfferingAction(raw.action)) { - throw Errors.badRequest("Invalid offering validation action", { - allowedActions: OFFERING_ACTIONS, - }); - } - - const rawOffering = raw.offering; - if (!rawOffering || typeof rawOffering !== "object") { - throw Errors.badRequest( - "Offering validation payload must include an offering object", - ); - } - - const offeringRecord = rawOffering as Record; - const payload: OfferingValidationPayload = { - action: raw.action, - offering: {}, - }; - - if (offeringRecord.id !== undefined) { - if (!isNonEmptyString(offeringRecord.id)) { - throw Errors.badRequest("offering.id must be a non-empty string"); - } - payload.offering.id = offeringRecord.id.trim(); - } - - if (offeringRecord.issuerId !== undefined) { - if (!isNonEmptyString(offeringRecord.issuerId)) { - throw Errors.badRequest("offering.issuerId must be a non-empty string"); - } - payload.offering.issuerId = offeringRecord.issuerId.trim(); - } - - if (offeringRecord.status !== undefined) { - if (!isOfferingStatus(offeringRecord.status)) { - throw Errors.badRequest( - "offering.status must be a supported offering status", - { - allowedStatuses: OFFERING_STATUSES, - }, - ); - } - payload.offering.status = offeringRecord.status as OfferingStatus; - } - - const stringFields: Array< - | "targetAmount" - | "minimumInvestment" - | "investmentAmount" - | "subscriptionStartsAt" - | "subscriptionEndsAt" - > = [ - "targetAmount", - "minimumInvestment", - "investmentAmount", - "subscriptionStartsAt", - "subscriptionEndsAt", - ]; - - for (const field of stringFields) { - const value = offeringRecord[field]; - if (value !== undefined) { - if (!isNonEmptyString(value, 64)) { - throw Errors.badRequest(`offering.${field} must be a non-empty string`); - } - payload.offering[field] = value.trim(); - } - } - - return payload; -} - -function evaluateOfferingValidationMatrix( - actor: AuthenticatedUser, - payload: OfferingValidationPayload, - now = new Date(), -): OfferingValidationResult { - const checks: ValidationCheck[] = []; - const { action, offering } = payload; - - const addCheck = ( - code: string, - passed: boolean, - message: string, - severity: DecisionSeverity = "error", - ): void => { - checks.push({ code, passed, message, severity }); - }; - - const isPrivileged = actor.role === "admin" || actor.role === "compliance"; - const isStartup = actor.role === "startup"; - const isInvestor = actor.role === "investor"; - const managesOffering = action !== "invest"; - const issuerKnown = typeof offering.issuerId === "string"; - const ownsOffering = issuerKnown && offering.issuerId === actor.id; - const targetAmount = parseMoneyString(offering.targetAmount); - const minimumInvestment = parseMoneyString(offering.minimumInvestment); - const investmentAmount = parseMoneyString(offering.investmentAmount); - const subscriptionStartsAt = parseIsoDate(offering.subscriptionStartsAt); - const subscriptionEndsAt = parseIsoDate(offering.subscriptionEndsAt); - - addCheck( - "ROLE_ALLOWED_FOR_ACTION", - isPrivileged || - (isStartup && - [ - "create", - "update", - "publish", - "pause", - "close", - "cancel", - "viewPrivate", - ].includes(action)) || - (isInvestor && action === "invest"), - `${actor.role} may not perform ${action} for offering workflows`, - ); - - if (managesOffering) { - addCheck( - "OWNERSHIP_CONFIRMED", - isPrivileged || action === "create" || !issuerKnown || ownsOffering, - "Offering management requires issuer ownership unless actor is privileged", - ); - } - - if (["create", "update", "publish"].includes(action)) { - addCheck( - "TARGET_AMOUNT_VALID", - targetAmount !== null && targetAmount > 0, - "targetAmount must be a positive decimal string with up to 2 fractional digits", - ); - - addCheck( - "MINIMUM_INVESTMENT_VALID", - minimumInvestment !== null && minimumInvestment > 0, - "minimumInvestment must be a positive decimal string with up to 2 fractional digits", - ); - - if (targetAmount !== null && minimumInvestment !== null) { - addCheck( - "MINIMUM_NOT_GREATER_THAN_TARGET", - minimumInvestment <= targetAmount, - "minimumInvestment cannot exceed targetAmount", - ); - } - } - - if (action === "publish") { - addCheck( - "STATUS_ELIGIBLE_FOR_PUBLISH", - offering.status === "draft", - "Only draft offerings may be published", - ); - addCheck( - "SUBSCRIPTION_START_VALID", - subscriptionStartsAt !== null, - "subscriptionStartsAt must be a valid ISO-8601 date", - ); - addCheck( - "SUBSCRIPTION_END_VALID", - subscriptionEndsAt !== null, - "subscriptionEndsAt must be a valid ISO-8601 date", - ); - - if (subscriptionStartsAt && subscriptionEndsAt) { - addCheck( - "SUBSCRIPTION_WINDOW_ORDERED", - subscriptionEndsAt.getTime() > subscriptionStartsAt.getTime(), - "subscriptionEndsAt must be later than subscriptionStartsAt", - ); - addCheck( - "SUBSCRIPTION_ENDS_IN_FUTURE", - subscriptionEndsAt.getTime() > now.getTime(), - "subscriptionEndsAt must be in the future when publishing", - ); - } - } - - if (action === "pause") { - addCheck( - "STATUS_ELIGIBLE_FOR_PAUSE", - offering.status === "open", - "Only open offerings may be paused", - ); - } - - if (action === "close") { - addCheck( - "STATUS_ELIGIBLE_FOR_CLOSE", - offering.status === "open" || offering.status === "paused", - "Only open or paused offerings may be closed", - ); - } - - if (action === "cancel") { - addCheck( - "STATUS_ELIGIBLE_FOR_CANCEL", - offering.status === "draft" || - offering.status === "open" || - offering.status === "paused", - "Only draft, open, or paused offerings may be cancelled", - ); - } - - if (action === "viewPrivate") { - addCheck( - "PRIVATE_VIEW_ALLOWED", - isPrivileged || (isStartup && (!issuerKnown || ownsOffering)), - "Private offering details are limited to privileged actors and the issuer", - ); - } - - if (action === "invest") { - addCheck( - "STATUS_OPEN_FOR_INVESTMENT", - offering.status === "open", - "Investments are accepted only while an offering is open", - ); - addCheck( - "INVESTMENT_AMOUNT_VALID", - investmentAmount !== null && investmentAmount > 0, - "investmentAmount must be a positive decimal string with up to 2 fractional digits", - ); - - if (minimumInvestment !== null && investmentAmount !== null) { - addCheck( - "INVESTMENT_MEETS_MINIMUM", - investmentAmount >= minimumInvestment, - "investmentAmount must be greater than or equal to minimumInvestment", - ); - } - - if (targetAmount !== null && investmentAmount !== null) { - addCheck( - "INVESTMENT_WITHIN_TARGET", - investmentAmount <= targetAmount, - "investmentAmount cannot exceed targetAmount for a single validation request", - "warning", - ); - } - - addCheck( - "INVESTOR_NOT_ISSUER", - !issuerKnown || offering.issuerId !== actor.id, - "Issuer self-investment is blocked by default pending explicit compliance approval", - ); - - if (subscriptionStartsAt && subscriptionEndsAt) { - addCheck( - "INVESTMENT_WINDOW_ACTIVE", - now.getTime() >= subscriptionStartsAt.getTime() && - now.getTime() <= subscriptionEndsAt.getTime(), - "Investments must occur within the subscription window", - ); - } else { - addCheck( - "INVESTMENT_WINDOW_ACTIVE", - false, - "subscriptionStartsAt and subscriptionEndsAt are required to validate investments", - ); - } - } - - const violations = checks.filter((check) => !check.passed); - return { - allowed: violations.length === 0, - decision: violations.length === 0 ? "allow" : "deny", - action, - actor, - offeringId: offering.id ?? null, - checks, - violations, - securityAssumptions: OFFERING_SECURITY_ASSUMPTIONS, - }; -} - -function createOfferingValidationHandler( - nowProvider: () => Date = () => new Date(), -): RequestHandler { - return (req: Request, res: Response, next: NextFunction): void => { - try { - const actor = (req as AuthenticatedRequest).user; - /* istanbul ignore next -- guarded by requireOfferingAuth middleware */ - if (!actor) { - next(Errors.unauthorized("Authenticated offering actor is required")); - return; - } - - const payload = parseOfferingValidationPayload(req.body); - const result = evaluateOfferingValidationMatrix( - actor, - payload, - nowProvider(), - ); - - res.status(result.allowed ? 200 : 422).json(result); - } catch (error) { - next(error); - } - }; -} - -let inFlightRequests = 0; - -export function createApp(dependencies: AppDependencies = {}): express.Express { - const app = express(); - - app.use((_req, res, next) => { - inFlightRequests++; - res.on('finish', () => inFlightRequests--); - res.on('close', () => { - if (!res.writableFinished) inFlightRequests--; - }); - next(); - }); - - const apiRouter = express.Router(); - const healthQuery = dependencies.healthQuery ?? dbQuery; - const healthStatus = dependencies.healthStatus ?? dbHealth; - - app.use(requestIdMiddleware()); - app.set("trust proxy", 1); - app.use(createCorsMiddleware() as RequestHandler); - app.use(express.json({ limit: "32kb" })); - app.use(morgan(env.NODE_ENV === "test" ? "tiny" : "dev")); - - app.get("/health", async (_req: Request, res: Response) => { - const db = await healthStatus(); - res.status(db.healthy ? 200 : 503).json({ - status: db.healthy ? "ok" : "degraded", - service: "revora-backend", - db, - }); - }); - - app.get("/health/failover", async (_req: Request, res: Response) => { - const region = process.env.REGION ?? env.REGION; - const activeRegion = process.env.FAILOVER_ACTIVE_REGION ?? env.FAILOVER_ACTIVE_REGION ?? region; - const db = await healthStatus(); - res.status(db.healthy ? 200 : 503).json({ - region, - activeRegion, - isActive: region === activeRegion, - db: db.healthy ? "up" : "down", - failoverActive: region !== activeRegion, - timestamp: new Date().toISOString(), - }); - }); - - app.use("/health", createHealthRouter(healthQuery as any, healthStatus, undefined, env.REGION)); - - apiRouter.get("/overview", (_req: Request, res: Response) => { - res.json({ - name: "Stellar RevenueShare (Revora) Backend", - description: - "Backend API skeleton for tokenized revenue-sharing on Stellar (offerings, investments, revenue distribution).", - version: "0.1.0", - }); - }); - - /** - * @notice Rate-limiter tier policy enforcement for the STARTUP_REGISTER endpoint. - * - * Security assumptions: - * - Tier resolution is performed via the `x-revora-rate-tier` request header. - * - Privileged tiers (`trusted`, `internal`) require a valid shared secret in - * `x-revora-tier-secret`; an absent, empty, or mismatched secret causes - * silent downgrade to the `standard` tier (fail-safe). - * - If no tier header is supplied, the request is treated as `standard`. - * - Rate-limit state is in-process; a distributed store (e.g. Redis) must be - * substituted for multi-instance deployments. - */ - const startupTierLimiter = createStartupAuthTierLimiter(); - apiRouter.post( - "/startup/register", - startupTierLimiter.middleware, - createStartupRegisterHandler(), - ); - - apiRouter.post( - "/offerings/validation-matrix", - requireOfferingAuth, - offeringSanitizeMiddleware, - createOfferingValidationHandler(), - ); - - apiRouter.use("/vesting", vestingRouter); - - // Mount password reset router - app.use(createPasswordResetRouter({ db: pool, emailService })); - - // Initialize email deliverability service (when enabled) - if (env.EMAIL_DELIVERABILITY_ENABLED) { - const emailDeliverabilityRepo = new EmailDeliverabilityRepository(pool); - const emailDeliverabilityService = new EmailDeliverabilityService( - emailDeliverabilityRepo, - new MetricsCollector({ enabled: true }), - { - enabled: env.EMAIL_DELIVERABILITY_ENABLED, - suppressionAutoExpireDays: env.SUPPRESSION_AUTO_EXPIRE_DAYS, - bounceRatioAlarmThreshold: env.BOUNCE_RATIO_ALARM_THRESHOLD, - }, - ); - - // Wire into the existing email service - emailService.setDeliverabilityService(emailDeliverabilityService); - - // Mount email bounce webhook routes - app.use( - '/api/v1/email/webhooks', - createEmailWebhooksRouter(emailDeliverabilityService, { - sendgridWebhookSecret: env.SENDGRID_EVENT_WEBHOOK_SECRET, - }), - ); - } - - // Initialize repositories for admin and audit routes - const auditLogRepo = new AuditLogRepository(pool); - const amlAuditRepo = new InMemorySecurityAuditRepository(); - const retentionLabelService = new RetentionLabelService( - new RetentionLabelRepository(pool), - auditLogRepo, - ); - const tenantSettingsRepo = new TenantSettingsRepository(pool); - const contractUpgradeService = env.STELLAR_SERVER_SECRET - ? new ContractUpgradeOrchestratorService( - pool, - auditLogRepo, - tenantSettingsRepo, - Keypair.fromSecret(env.STELLAR_SERVER_SECRET), - ) - : null; - - // Mount admin router - apiRouter.use("/admin", createAdminRouter(auditLogRepo, retentionLabelService)); - apiRouter.use("/admin", createAdminKycRiskTierRouter(pool, amlAuditRepo)); - - // Mount admin webhook dead-letter routes - const webhookEndpointRepo = new WebhookEndpointRepository(pool); - apiRouter.use("/admin/webhooks", createAdminWebhookRouter({ webhookEndpointRepo })); - - // Mount admin ledger double-entry export (RBAC + audited) - apiRouter.use( - "/admin/ledger", - createAdminLedgerExportRouter({ - distributionAccountRepo: new DistributionRepository(pool), - accountingLedger: new AccountingLedgerService(), - auditLogRepo, - }), - ); - - if (contractUpgradeService) { - apiRouter.use( - "/contract-upgrades", - createContractUpgradeRouter(contractUpgradeService), - ); - } - - // Initialize AML service and routes - const amlService = createAMLService(pool, amlAuditRepo, 'system'); - apiRouter.use("/aml", createAMLRoutes(amlService)); - - // Initialize sanctions list versioning and compliance routes - const sanctionsVersionsRepo = new SanctionsListVersionsRepository(pool); - const sanctionsListDiffService = new SanctionsListDiffService(sanctionsVersionsRepo); - apiRouter.use("/compliance", createComplianceRouter(sanctionsVersionsRepo, sanctionsListDiffService)); - - // Initialize ledger export with in-memory repository - // TODO: Replace with PgLedgerEntryRepository when ledger_entries table exists - const ledgerRepo = new InMemoryLedgerRepository(); - const ledgerExportService = new LedgerExportService(ledgerRepo); - apiRouter.use("/ledger", createLedgerExportRouter(ledgerExportService)); - - // Investor statements (Issue #874): the fetch endpoint re-verifies the - // persisted sha256 before serving. Storage defaults to in-memory — replace - // with the S3-backed adapter when one is deployed so completed renders are - // retrievable across instances. Without a storage adapter, requests simply - // 404 (no artifacts exist), which is fail-safe. - const statementStorage = new InMemoryStatementPdfStorage(); - const pdfRenderJobRepo = new PdfRenderJobRepository(pool); - const sessionRepo = new SessionRepository(pool); - apiRouter.use( - "/statements", - createStatementsRouter({ - jobRepo: pdfRenderJobRepo, - storage: statementStorage, - verifyJWT: createRequireAuth(sessionRepo), - }), - ); - - // Mount taxation routes for per-lot cost-basis tax reporting - app.use(API_VERSION_PREFIX + '/taxation', taxationRouter); - - // Expose reconciliation alarms and discrepancy metrics (OpenMetrics subset). - // Guards alarms via bearer token in production; bypassed in dev/test. - app.get( - "/metrics/reconciliation", - createMetricsAuthMiddleware(), - createReconciliationMetricsHandler(globalMetrics), - ); - - app.use(API_VERSION_PREFIX, apiRouter); - app.use((_req, _res, next) => next(Errors.notFound("Route not found"))); - app.use(errorHandler); - - return app; -} - -export const __test = { - stableSerialize, - parseMoneyString, - parseIsoDate, - parseOfferingValidationPayload, - evaluateOfferingValidationMatrix, - /** - * @dev Exposes the tier-limiter factory for integration tests that need to - * inspect tier resolution or reset counters without restarting the app. - */ - createStartupAuthTierLimiter, - /** - * @dev Exposes the OFAC loader for integration tests. - */ - OfacSanctionsLoader, -}; - -export { classifyStellarRPCFailure, StellarRPCFailureClass }; - -export const app = createApp(); - -let isShuttingDown = false; - -/* istanbul ignore next -- exercised only in real process shutdown */ -async function shutdown(signal: string): Promise { - if (isShuttingDown) return; - isShuttingDown = true; - - globalSampler.stop(); - console.log(`\n[server] ${signal} shutting down`); - - if (server) { - const drainTimeoutMs = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || '10000', 10); - - // Stop accepting new connections - const serverClosePromise = new Promise((resolve, reject) => { - server!.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); - - console.log('[server] Stopped accepting new connections. Draining in-flight requests...'); - - const drainStart = Date.now(); - while (inFlightRequests > 0) { - if (Date.now() - drainStart > drainTimeoutMs) { - console.warn(`[server] Drain timeout exceeded with ${inFlightRequests} in-flight requests. Forcing exit.`); - break; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - - if (inFlightRequests === 0) { - console.log('[server] All in-flight requests drained.'); - // Wait for server to fully close (e.g., closing idle keep-alive sockets) - try { - const remainingTime = Math.max(0, drainTimeoutMs - (Date.now() - drainStart)); - await Promise.race([ - serverClosePromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), remainingTime)) - ]); - console.log('[server] Listener closed completely.'); - } catch (err) { - console.warn('[server] Listener close timeout or error. Proceeding to close pool.'); - } - } - } - - await closePool(); - /* istanbul ignore next -- process exit is not unit-test friendly */ - process.exit(0); -} - -let server: ReturnType | undefined; - -/* istanbul ignore next -- setter exists for runtime wiring compatibility */ -export const setServer = (value: ReturnType) => { - server = value; -}; - -/** - * Webhook delivery queue with exponential backoff, SSRF-aware URL blocking, - * bounded depth, and back-pressure via deferred persistence. - * - * @notice When in-flight count reaches WEBHOOK_QUEUE_MAX_DEPTH the delivery is - * persisted as 'deferred' (never dropped) and webhook_queue_shed_total - * is incremented. Call resumeDeferred() to re-enqueue them once capacity - * is available. Shedding is idempotent per delivery: re-enqueueing an - * already-deferred row at capacity reuses the row and does not - * double-count the metric. - */ -export class WebhookQueue { - private static repo: WebhookEndpointRepository; - private static service: WebhookService; - private static MAX_RETRIES = 5; - private static INITIAL_DELAY = 1000; - /** Number of deliveries currently scheduled / in-flight. */ - private static inFlight = 0; - - static init(repo: WebhookEndpointRepository, service: WebhookService) { - this.repo = repo; - this.service = service; - } - - private static get maxDepth(): number { - return env.WEBHOOK_QUEUE_MAX_DEPTH; - } - - private static async isSafeUrl(url: string): Promise { - try { - const result = await validateWebhookUrl(url, true); - if (!result.valid) { - console.error( - `[Security] SSRF validation failed for ${url}: ${result.error?.message}`, - ); - } - return result.valid; - } catch (error) { - console.error(`[Security] Error validating webhook URL ${url}:`, error); - return false; - } - } - - static getBackoffDelay(retryCount: number): number { - if (retryCount >= this.MAX_RETRIES) return -1; - return this.INITIAL_DELAY * Math.pow(2, retryCount); - } - - /** - * Count a shed delivery. Invoked only when a delivery first transitions to - * deferred so the counter is idempotent across retries of the same row. - */ - private static recordShed(endpointId: string): void { - globalMetrics.incrementCounter( - WEBHOOK_QUEUE_SHED_TOTAL, - { endpoint: endpointId }, - 1, - 'Total webhook deliveries deferred due to queue depth limit', - ); - } - - /** - * Attempt delivery of a webhook payload to an active endpoint. - * - * @dev Back-pressure contract: when the bounded queue is at capacity the - * delivery is persisted with status 'deferred' (never dropped) and the - * webhook_queue_shed_total counter is incremented exactly once per - * status transition. When a retry is re-enqueued with `deliveryId`, the - * same row is deferred instead of a duplicate being inserted, so retries - * are idempotent and preserve the attempt counter. - * @param url Webhook endpoint URL (SSRF validation precedes any write) - * @param payload Event payload to deliver - * @param deliveryId Existing delivery row to reuse (retry path) - * @returns true when delivered, false when deferred/failed/absent endpoint - */ - static async processDelivery( - url: string, - payload: any, - deliveryId?: string, - ): Promise { - if (!this.repo || !this.service) { - console.error("[WebhookQueue] Not initialized"); - return false; - } - - if (!(await this.isSafeUrl(url))) { - console.error(`[Security] Blocked unsafe webhook URL: ${url}`); - return false; - } - - const endpoint = await this.repo.findByUrl(url); - if (!endpoint) { - console.error(`[WebhookQueue] No active endpoint found for URL: ${url}`); - return false; - } - - // --- Back-pressure: defer when at capacity --- - if (this.inFlight >= this.maxDepth) { - // When a retry is re-enqueued (deliveryId known), defer that same row - // instead of inserting a duplicate so attempts/backoff state are kept and - // the shed counter stays idempotent across retries of the same delivery. - let deferred: WebhookDelivery | null = deliveryId - ? await this.repo.findDeliveryById(deliveryId) - : null; - - let deferredId: string; - if (!deferred) { - deferred = await this.repo.createDelivery({ - endpoint_id: endpoint.id, - payload, - status: 'deferred', - attempts: 0, - }); - deferredId = deferred.id; - this.recordShed(endpoint.id); - } else { - deferredId = deferred.id; - if (deferred.status !== 'deferred') { - await this.repo.updateDelivery(deferred.id, { - status: 'deferred', - }); - this.recordShed(endpoint.id); - } - } - - globalMetrics.setGauge( - WEBHOOK_QUEUE_DEPTH_GAUGE, - this.inFlight, - {}, - 'Current in-flight webhook deliveries when the queue sheds a delivery', - ); - console.warn( - `[WebhookQueue] Queue full (${this.inFlight}/${this.maxDepth}), deferred delivery ${deferredId}`, - ); - return false; - } - - let delivery: WebhookDelivery | null = null; - if (deliveryId) delivery = await this.repo.findDeliveryById(deliveryId); - - if (!delivery) { - delivery = await this.repo.createDelivery({ - endpoint_id: endpoint.id, - payload, - status: "pending", - attempts: 0, - }); - } - - this.inFlight++; - try { - return await this._attempt(endpoint, delivery, payload); - } finally { - this.inFlight--; - } - } - - private static async _attempt( - endpoint: { id: string; url: string; secret: string }, - delivery: WebhookDelivery, - payload: any, - ): Promise { - const currentAttempt = delivery.attempts + 1; - - // Propagate the transactional outbox event_id when present (idempotency key - // the receiver's webhookEventOrdering relies on to deduplicate retries), and - // fall back to the delivery row id for legacy callers. - const webhookPayload: WebhookPayload = { - id: (payload?.id as string) || delivery.id, - event: (payload as any).event || WebhookEventType.OFFERING_UPDATED, - payload: (payload as any).payload || payload, - timestamp: new Date().toISOString(), - }; - - const result = await this.service.sendAttempt( - { id: endpoint.id, url: endpoint.url, secret: endpoint.secret }, - webhookPayload, - ); - - if (result.success) { - await this.repo.updateDelivery(delivery.id, { - status: "completed", - attempts: currentAttempt, - last_error: null, - next_retry_at: null, - }); - return true; - } - - const isRetryable = - !result.statusCode || - result.statusCode >= 500 || - result.statusCode === 429; - const nextDelay = this.getBackoffDelay(currentAttempt); - - if (isRetryable && nextDelay !== -1) { - const nextRetryAt = new Date(Date.now() + nextDelay); - await this.repo.updateDelivery(delivery.id, { - attempts: currentAttempt, - last_error: result.error, - next_retry_at: nextRetryAt, - }); - - setTimeout(() => { - void this.processDelivery(endpoint.url, payload, delivery.id); - }, nextDelay); - - return false; - } - - await this.repo.updateDelivery(delivery.id, { - status: nextDelay === -1 ? "dead_letter" : "failed", - attempts: currentAttempt, - last_error: result.error, - next_retry_at: null, - }); - - if (nextDelay === -1) { - try { - const count = await this.repo.countDeadLettersByEndpoint(delivery.endpoint_id); - globalMetrics.setGauge( - 'webhook_dead_letter_total', - count, - { endpoint: endpoint.id }, - 'Number of dead-lettered webhook deliveries per endpoint', - ); - } catch (err) { - console.error('[WebhookQueue] Failed to update dead-letter metric:', err); - } - } - return false; - } - - static async resumePending(): Promise { - if (!this.repo) return; - const pending = await this.repo.getPendingDeliveries(); - for (const delivery of pending) { - // Honour the same bounded-depth contract as resumeDeferred; excess rows - // stay pending and are picked up on the next resume cycle. - if (this.inFlight >= this.maxDepth) break; - const endpoint = await this.repo.findById(delivery.endpoint_id); - if (endpoint) { - void this.processDelivery(endpoint.url, delivery.payload, delivery.id); - } - } - } - - /** - * Re-enqueue deferred deliveries up to available capacity. - * Safe to call repeatedly; excess deferred rows remain deferred. - */ - static async resumeDeferred(): Promise { - if (!this.repo) return; - const deferred = await this.repo.getDeferredDeliveries(); - for (const delivery of deferred) { - if (this.inFlight >= this.maxDepth) break; - const endpoint = await this.repo.findById(delivery.endpoint_id); - if (!endpoint) continue; - // Promote back to pending so processDelivery can pick it up - await this.repo.updateDelivery(delivery.id, { status: 'pending' }); - void this.processDelivery(endpoint.url, delivery.payload, delivery.id); - } - } -} - -/* istanbul ignore next -- bootstrapping is integration-environment specific */ -if (require.main === module && env.NODE_ENV !== "test") { - process.on("SIGTERM", () => { - void shutdown("SIGTERM"); - }); - process.on("SIGINT", () => { - void shutdown("SIGINT"); - }); - - const backgroundStopFns: (() => void)[] = []; - - // Resolve worker role — fail-fast on invalid value - const { resolveWorkerRole, getRoleConfig } = require("./config/workerRole"); - const workerRole = resolveWorkerRole(env.ROLE, env.NODE_ENV); - const roleConfig = getRoleConfig(workerRole); - console.log(`[server] Starting with role="${workerRole}"`, roleConfig); - - const metricsCollector = new MetricsCollector(); - - const payoutDriftRepo = new PayoutDriftRepository(pool); - const payoutDriftDetector = new PayoutDriftDetector( - pool, - payoutDriftRepo, - metricsCollector - ); - - payoutDriftDetector.start(); // Start nightly payout drift detection - globalSampler.start(); // Start event loop lag monitoring - - if (roleConfig.auditPurge) { - const auditLogRepo = new AuditLogRepository(pool); - const auditPurgeService = new AuditPurgeService(auditLogRepo, metricsCollector); - auditPurgeService.start(); - backgroundStopFns.push(() => auditPurgeService.stop()); - console.log("[server] AuditPurgeService started"); - } - - if (roleConfig.auditPurge) { // Reusing auditPurge role for general cleanup tasks - const sessionRepo = new SessionRepository(pool); - const sessionCompactionService = new SessionCompactionService(sessionRepo, metricsCollector); - sessionCompactionService.start(); - backgroundStopFns.push(() => sessionCompactionService.stop()); - console.log("[server] SessionCompactionService started"); - } - - if (roleConfig.payoutDrift) { - const payoutDriftRepo = new PayoutDriftRepository(pool); - const payoutDriftDetector = new PayoutDriftDetector( - pool, - payoutDriftRepo, - metricsCollector, - ); - payoutDriftDetector.start(); - backgroundStopFns.push(() => payoutDriftDetector.stop()); - console.log("[server] PayoutDriftDetector started"); - } - - if (roleConfig.reconciliation) { - const reconciliationScheduler = createReconciliationSchedulerRuntime({ - db: pool, - metrics: globalMetrics, - logger: undefined, - }); - reconciliationScheduler.start(); - backgroundStopFns.push(() => reconciliationScheduler.stop()); - console.log("[server] ReconciliationScheduler started"); - } - - // --- Hot-path services (only for "api" and "all" roles) --- - - if (roleConfig.webhookQueue) { - const repo = new WebhookEndpointRepository(pool); - const service = new WebhookService(repo, { - outboxRepo: env.OUTBOX_DISPATCHER_ENABLED ? new OutboxRepository(pool) : undefined, - }); - WebhookQueue.init(repo, service); - void WebhookQueue.resumePending(); - console.log("[server] WebhookQueue started"); - - // Drain the transactional outbox in a separate polling worker. Every - // outbox row was written atomically with the transaction that produced - // the event; retries reuse the same event_id so receivers can deduplicate - // via webhookEventOrdering (exactly-once). - if (env.OUTBOX_DISPATCHER_ENABLED) { - const outboxRepo = new OutboxRepository(pool); - const dispatcher = new OutboxDispatcher( - outboxRepo, - makeWebhookDispatchFn( - WebhookQueue.processDelivery.bind(WebhookQueue), - (event) => repo.listActiveByEvent(event), - ), - ); - dispatcher.start(); - backgroundStopFns.push(() => dispatcher.stop()); - console.log("[server] OutboxDispatcher started"); - } - } - - for (const stopFn of backgroundStopFns) { - process.on("SIGTERM", stopFn); - process.on("SIGINT", stopFn); - } - - // --- HTTP server (only for "api" and "all" roles) --- - - if (roleConfig.httpServer) { - server = app.listen(port, () => { - console.log(`revora-backend listening on http://localhost:${port} (role=${workerRole})`); - }); - } else { - console.log(`[server] HTTP server disabled for role="${workerRole}". Running background workers only.`); - } -} - -export default app; +import "dotenv/config"; +import { timingSafeEqual } from "crypto"; +import express, { + NextFunction, + Request, + RequestHandler, + Response, +} from "express"; +import morgan from "morgan"; +import { closePool, dbHealth, query as dbQuery } from "./db/client"; +import { createCorsMiddleware } from "./middleware/cors"; +import { errorHandler } from "./middleware/errorHandler"; +import { requestIdMiddleware } from "./middleware/requestId"; +import { Errors } from "./lib/errors"; +import { globalSampler } from "./lib/sampler"; +import { + classifyStellarRPCFailure, + StellarRPCFailureClass, +} from "./lib/stellarRpcFailure"; +import { createHealthRouter } from "./routes/health"; +import vestingRouter from "./routes/vesting"; +import { offeringSanitizeMiddleware } from "./middleware/offeringSanitize"; +import { createStartupAuthTierLimiter } from "./middleware/startupAuthRateTierPolicy"; +import { env } from "./config/env"; +import { validateWebhookUrl, SsrfValidationError } from "./lib/ssrfProtection"; +import { + WebhookEndpointRepository, + WebhookDelivery, +} from "./db/repositories/webhookEndpointRepository"; +import { + WebhookService, + WebhookPayload, + WebhookEventType, +} from "./services/webhookService"; +import { OutboxRepository } from "./db/repositories/outboxRepository"; +import { OutboxDispatcher, makeWebhookDispatchFn } from "./services/outboxDispatcher"; +import { pool } from "./db/pool"; +import { + globalMetrics, + WEBHOOK_QUEUE_DEPTH_GAUGE, + WEBHOOK_QUEUE_SHED_TOTAL, +} from "./lib/metrics"; +import { createPasswordResetRouter } from "./routes/passwordReset"; +import { emailService } from "./services/emailService"; +import { EmailDeliverabilityService } from "./services/emailDeliverabilityService"; +import { EmailDeliverabilityRepository } from "./db/repositories/emailDeliverabilityRepository"; +import { createEmailWebhooksRouter } from "./routes/emailWebhooks"; +import { createAdminRouter } from "./routes/admin"; +import { createAdminLedgerExportRouter } from "./routes/adminLedgerExport"; +import { createAdminWebhookRouter } from "./routes/adminWebhooks"; +import { AccountingLedgerService } from "./services/accountingLedgerService"; +import { DistributionRepository } from "./db/repositories/distributionRepository"; +import { createAdminKycRiskTierRouter } from "./routes/adminKycRiskTier"; +import { AuditLogRepository } from "./db/repositories/auditLogRepository"; +import { TenantSettingsRepository } from "./db/repositories/tenantSettingsRepository"; +import { ContractUpgradeOrchestratorService } from "./services/contractUpgradeOrchestratorService"; +import { createContractUpgradeRouter } from "./routes/contractUpgradeRoutes"; +import { AuditPurgeService } from "./services/auditPurgeService"; +import { SessionCompactionService } from "./services/sessionCompactionService"; +import { SessionRepository } from "./db/repositories/sessionRepository"; +import { RetentionLabelRepository } from "./db/repositories/retentionLabelRepository"; +import { RetentionLabelService } from "./services/retentionLabelService"; +import { PayoutDriftRepository } from "./db/repositories/payoutDriftRepository"; +import { PayoutDriftDetector } from "./services/payoutDriftDetector"; +import { MetricsCollector } from "./lib/metrics"; +import { createReconciliationMetricsHandler } from "./routes/reconciliationRoutes"; +import { createReconciliationSchedulerRuntime } from "./services/reconciliationScheduler"; +import { createAMLRoutes } from "./routes/amlRoutes"; +import { createLedgerExportRouter } from "./routes/ledgerExport"; +import { LedgerExportService, InMemoryLedgerRepository } from "./services/ledgerExportService"; +import { createAMLService } from "./aml/amlService"; +import { InMemorySecurityAuditRepository } from "./security/audit"; +import { createMobileCompanionRouter } from "./routes/mobileCompanion"; +import { InMemoryDeviceKeyStore } from "./middleware/deviceSignature"; +import { Keypair } from '@stellar/stellar-sdk'; +import { OfacSanctionsLoader } from './services/ofacSanctionsLoader'; +import { SanctionsListVersionsRepository } from './db/repositories/sanctionsListVersionsRepository'; +import { SanctionsListDiffService } from './services/sanctionsListDiffService'; +import { createComplianceRouter } from './routes/compliance'; +import { createScimRouter } from './routes/scim'; +import { UserRepository } from './db/repositories/userRepository'; +import taxationRouter from './routes/taxation'; +import { PdfRenderJobRepository } from './db/repositories/pdfRenderJobRepository'; +import { InMemoryStatementPdfStorage } from './services/statementPdfService'; +import createStatementsRouter from './routes/statements'; +import { createRequireAuth } from './middleware/auth'; + +const port = env.PORT; +const API_VERSION_PREFIX = env.API_VERSION_PREFIX; + +const OFFERING_ROLES = ["startup", "admin", "compliance", "investor"] as const; +const OFFERING_ACTIONS = [ + "create", + "update", + "publish", + "pause", + "close", + "cancel", + "viewPrivate", + "invest", +] as const; +const OFFERING_STATUSES = [ + "draft", + "open", + "paused", + "closed", + "cancelled", + "completed", +] as const; +const OFFERING_SECURITY_ASSUMPTIONS = [ + "Caller identity is asserted by trusted upstream auth middleware before these rules are used for authorization.", + "Money amounts are decimal strings to avoid binary rounding; invalid or unbounded numeric input is rejected.", + "Startup actors may only manage offerings they issued unless a privileged admin or compliance actor performs the action.", + "Validation output is safe for clients and never includes raw database, token, or upstream provider error messages.", +] as const; + +type OfferingActorRole = (typeof OFFERING_ROLES)[number]; +type OfferingValidationAction = (typeof OFFERING_ACTIONS)[number]; +type OfferingStatus = (typeof OFFERING_STATUSES)[number]; +type DecisionSeverity = "error" | "warning"; + +interface AuthenticatedUser { + id: string; + role: OfferingActorRole; +} + +interface AuthenticatedRequest extends Request { + user?: AuthenticatedUser; +} + +interface AppDependencies { + healthQuery?: typeof dbQuery; + healthStatus?: typeof dbHealth; +} + +/** + * Bearer-token guard for the reconciliation metrics endpoint. + * + * Security assumptions: + * - In production a `METRICS_TOKEN` MUST be set; otherwise the endpoint is + * down (503) rather than exposed unauthenticated. + * - Token comparison uses a constant-time compare to avoid timing oracles. + * - In `development`/`test` the guard is bypassed for operator convenience, + * mirroring the existing Prometheus `/metrics` endpoint behaviour. + */ +function createMetricsAuthMiddleware(): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const metricsToken = process.env.METRICS_TOKEN; + const nodeEnv = process.env.NODE_ENV; + + if (nodeEnv === "development" || nodeEnv === "test") { + next(); + return; + } + if (!metricsToken) { + res.status(503).json({ + error: "Metrics endpoint not configured", + message: "METRICS_TOKEN environment variable must be set", + }); + return; + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized", message: "Bearer token required" }); + return; + } + const token = authHeader.substring(7); + const matches = + token.length === metricsToken.length && + timingSafeEqual(Buffer.from(token), Buffer.from(metricsToken)); + if (!matches) { + res.status(401).json({ error: "Unauthorized", message: "Invalid token" }); + return; + } + next(); + }; +} + +interface OfferingValidationPayload { + action: OfferingValidationAction; + offering: { + id?: string; + issuerId?: string; + status?: OfferingStatus; + targetAmount?: string; + minimumInvestment?: string; + investmentAmount?: string; + subscriptionStartsAt?: string; + subscriptionEndsAt?: string; + }; +} + +interface ValidationCheck { + code: string; + passed: boolean; + severity: DecisionSeverity; + message: string; +} + +interface OfferingValidationResult { + allowed: boolean; + decision: "allow" | "deny"; + action: OfferingValidationAction; + actor: AuthenticatedUser; + offeringId: string | null; + checks: ValidationCheck[]; + violations: ValidationCheck[]; + securityAssumptions: readonly string[]; +} + +/** + * @dev Stable JSON serializer used for deterministic fingerprints and tests. + */ +function stableSerialize(value: unknown): string { + const normalize = (input: unknown): unknown => { + if (Array.isArray(input)) { + return input.map(normalize); + } + + if (input && typeof input === "object") { + const record = input as Record; + const sorted: Record = {}; + for (const key of Object.keys(record).sort()) { + sorted[key] = normalize(record[key]); + } + return sorted; + } + + return input; + }; + + return JSON.stringify(normalize(value)); +} + +function isOfferingRole(value: unknown): value is OfferingActorRole { + return ( + typeof value === "string" && + (OFFERING_ROLES as readonly string[]).includes(value) + ); +} + +function isOfferingAction(value: unknown): value is OfferingValidationAction { + return ( + typeof value === "string" && + (OFFERING_ACTIONS as readonly string[]).includes(value) + ); +} + +function isOfferingStatus(value: unknown): value is OfferingStatus { + return ( + typeof value === "string" && + (OFFERING_STATUSES as readonly string[]).includes(value) + ); +} + +function isNonEmptyString(value: unknown, maxLength = 128): value is string { + return ( + typeof value === "string" && + value.trim().length > 0 && + value.trim().length <= maxLength + ); +} + +/** + * @dev Decimal parser with strict input bounds to resist coercion abuse and NaN payloads. + */ +function parseMoneyString(value: unknown): number | null { + if (typeof value !== "string") return null; + if (!/^(0|[1-9]\d{0,11})(\.\d{1,2})?$/.test(value)) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return parsed; +} + +function parseIsoDate(value: unknown): Date | null { + if (!isNonEmptyString(value, 64)) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return null; + return parsed; +} + +function createStartupRegisterHandler(): RequestHandler { + return (req: Request, res: Response): void => { + const body = req.body as Record | undefined; + const email = body?.email; + const password = body?.password; + + if (!isNonEmptyString(email) || !isNonEmptyString(password)) { + res.status(400).json({ error: "Email and password are required" }); + return; + } + + res.status(201).json({ message: "Startup user registered successfully" }); + }; +} + +function requireOfferingAuth( + req: Request, + _res: Response, + next: NextFunction, +): void { + const userId = req.header("x-user-id"); + const role = req.header("x-user-role"); + + if (!isNonEmptyString(userId) || !isOfferingRole(role)) { + next( + Errors.unauthorized( + "Offering validation requires x-user-id and x-user-role headers", + ), + ); + return; + } + + (req as AuthenticatedRequest).user = { id: userId.trim(), role }; + next(); +} + +function parseOfferingValidationPayload( + body: unknown, +): OfferingValidationPayload { + if (!body || typeof body !== "object") { + throw Errors.badRequest("Validation payload must be a JSON object"); + } + + const raw = body as Record; + if (!isOfferingAction(raw.action)) { + throw Errors.badRequest("Invalid offering validation action", { + allowedActions: OFFERING_ACTIONS, + }); + } + + const rawOffering = raw.offering; + if (!rawOffering || typeof rawOffering !== "object") { + throw Errors.badRequest( + "Offering validation payload must include an offering object", + ); + } + + const offeringRecord = rawOffering as Record; + const payload: OfferingValidationPayload = { + action: raw.action, + offering: {}, + }; + + if (offeringRecord.id !== undefined) { + if (!isNonEmptyString(offeringRecord.id)) { + throw Errors.badRequest("offering.id must be a non-empty string"); + } + payload.offering.id = offeringRecord.id.trim(); + } + + if (offeringRecord.issuerId !== undefined) { + if (!isNonEmptyString(offeringRecord.issuerId)) { + throw Errors.badRequest("offering.issuerId must be a non-empty string"); + } + payload.offering.issuerId = offeringRecord.issuerId.trim(); + } + + if (offeringRecord.status !== undefined) { + if (!isOfferingStatus(offeringRecord.status)) { + throw Errors.badRequest( + "offering.status must be a supported offering status", + { + allowedStatuses: OFFERING_STATUSES, + }, + ); + } + payload.offering.status = offeringRecord.status as OfferingStatus; + } + + const stringFields: Array< + | "targetAmount" + | "minimumInvestment" + | "investmentAmount" + | "subscriptionStartsAt" + | "subscriptionEndsAt" + > = [ + "targetAmount", + "minimumInvestment", + "investmentAmount", + "subscriptionStartsAt", + "subscriptionEndsAt", + ]; + + for (const field of stringFields) { + const value = offeringRecord[field]; + if (value !== undefined) { + if (!isNonEmptyString(value, 64)) { + throw Errors.badRequest(`offering.${field} must be a non-empty string`); + } + payload.offering[field] = value.trim(); + } + } + + return payload; +} + +function evaluateOfferingValidationMatrix( + actor: AuthenticatedUser, + payload: OfferingValidationPayload, + now = new Date(), +): OfferingValidationResult { + const checks: ValidationCheck[] = []; + const { action, offering } = payload; + + const addCheck = ( + code: string, + passed: boolean, + message: string, + severity: DecisionSeverity = "error", + ): void => { + checks.push({ code, passed, message, severity }); + }; + + const isPrivileged = actor.role === "admin" || actor.role === "compliance"; + const isStartup = actor.role === "startup"; + const isInvestor = actor.role === "investor"; + const managesOffering = action !== "invest"; + const issuerKnown = typeof offering.issuerId === "string"; + const ownsOffering = issuerKnown && offering.issuerId === actor.id; + const targetAmount = parseMoneyString(offering.targetAmount); + const minimumInvestment = parseMoneyString(offering.minimumInvestment); + const investmentAmount = parseMoneyString(offering.investmentAmount); + const subscriptionStartsAt = parseIsoDate(offering.subscriptionStartsAt); + const subscriptionEndsAt = parseIsoDate(offering.subscriptionEndsAt); + + addCheck( + "ROLE_ALLOWED_FOR_ACTION", + isPrivileged || + (isStartup && + [ + "create", + "update", + "publish", + "pause", + "close", + "cancel", + "viewPrivate", + ].includes(action)) || + (isInvestor && action === "invest"), + `${actor.role} may not perform ${action} for offering workflows`, + ); + + if (managesOffering) { + addCheck( + "OWNERSHIP_CONFIRMED", + isPrivileged || action === "create" || !issuerKnown || ownsOffering, + "Offering management requires issuer ownership unless actor is privileged", + ); + } + + if (["create", "update", "publish"].includes(action)) { + addCheck( + "TARGET_AMOUNT_VALID", + targetAmount !== null && targetAmount > 0, + "targetAmount must be a positive decimal string with up to 2 fractional digits", + ); + + addCheck( + "MINIMUM_INVESTMENT_VALID", + minimumInvestment !== null && minimumInvestment > 0, + "minimumInvestment must be a positive decimal string with up to 2 fractional digits", + ); + + if (targetAmount !== null && minimumInvestment !== null) { + addCheck( + "MINIMUM_NOT_GREATER_THAN_TARGET", + minimumInvestment <= targetAmount, + "minimumInvestment cannot exceed targetAmount", + ); + } + } + + if (action === "publish") { + addCheck( + "STATUS_ELIGIBLE_FOR_PUBLISH", + offering.status === "draft", + "Only draft offerings may be published", + ); + addCheck( + "SUBSCRIPTION_START_VALID", + subscriptionStartsAt !== null, + "subscriptionStartsAt must be a valid ISO-8601 date", + ); + addCheck( + "SUBSCRIPTION_END_VALID", + subscriptionEndsAt !== null, + "subscriptionEndsAt must be a valid ISO-8601 date", + ); + + if (subscriptionStartsAt && subscriptionEndsAt) { + addCheck( + "SUBSCRIPTION_WINDOW_ORDERED", + subscriptionEndsAt.getTime() > subscriptionStartsAt.getTime(), + "subscriptionEndsAt must be later than subscriptionStartsAt", + ); + addCheck( + "SUBSCRIPTION_ENDS_IN_FUTURE", + subscriptionEndsAt.getTime() > now.getTime(), + "subscriptionEndsAt must be in the future when publishing", + ); + } + } + + if (action === "pause") { + addCheck( + "STATUS_ELIGIBLE_FOR_PAUSE", + offering.status === "open", + "Only open offerings may be paused", + ); + } + + if (action === "close") { + addCheck( + "STATUS_ELIGIBLE_FOR_CLOSE", + offering.status === "open" || offering.status === "paused", + "Only open or paused offerings may be closed", + ); + } + + if (action === "cancel") { + addCheck( + "STATUS_ELIGIBLE_FOR_CANCEL", + offering.status === "draft" || + offering.status === "open" || + offering.status === "paused", + "Only draft, open, or paused offerings may be cancelled", + ); + } + + if (action === "viewPrivate") { + addCheck( + "PRIVATE_VIEW_ALLOWED", + isPrivileged || (isStartup && (!issuerKnown || ownsOffering)), + "Private offering details are limited to privileged actors and the issuer", + ); + } + + if (action === "invest") { + addCheck( + "STATUS_OPEN_FOR_INVESTMENT", + offering.status === "open", + "Investments are accepted only while an offering is open", + ); + addCheck( + "INVESTMENT_AMOUNT_VALID", + investmentAmount !== null && investmentAmount > 0, + "investmentAmount must be a positive decimal string with up to 2 fractional digits", + ); + + if (minimumInvestment !== null && investmentAmount !== null) { + addCheck( + "INVESTMENT_MEETS_MINIMUM", + investmentAmount >= minimumInvestment, + "investmentAmount must be greater than or equal to minimumInvestment", + ); + } + + if (targetAmount !== null && investmentAmount !== null) { + addCheck( + "INVESTMENT_WITHIN_TARGET", + investmentAmount <= targetAmount, + "investmentAmount cannot exceed targetAmount for a single validation request", + "warning", + ); + } + + addCheck( + "INVESTOR_NOT_ISSUER", + !issuerKnown || offering.issuerId !== actor.id, + "Issuer self-investment is blocked by default pending explicit compliance approval", + ); + + if (subscriptionStartsAt && subscriptionEndsAt) { + addCheck( + "INVESTMENT_WINDOW_ACTIVE", + now.getTime() >= subscriptionStartsAt.getTime() && + now.getTime() <= subscriptionEndsAt.getTime(), + "Investments must occur within the subscription window", + ); + } else { + addCheck( + "INVESTMENT_WINDOW_ACTIVE", + false, + "subscriptionStartsAt and subscriptionEndsAt are required to validate investments", + ); + } + } + + const violations = checks.filter((check) => !check.passed); + return { + allowed: violations.length === 0, + decision: violations.length === 0 ? "allow" : "deny", + action, + actor, + offeringId: offering.id ?? null, + checks, + violations, + securityAssumptions: OFFERING_SECURITY_ASSUMPTIONS, + }; +} + +function createOfferingValidationHandler( + nowProvider: () => Date = () => new Date(), +): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + try { + const actor = (req as AuthenticatedRequest).user; + /* istanbul ignore next -- guarded by requireOfferingAuth middleware */ + if (!actor) { + next(Errors.unauthorized("Authenticated offering actor is required")); + return; + } + + const payload = parseOfferingValidationPayload(req.body); + const result = evaluateOfferingValidationMatrix( + actor, + payload, + nowProvider(), + ); + + res.status(result.allowed ? 200 : 422).json(result); + } catch (error) { + next(error); + } + }; +} + +let inFlightRequests = 0; + +export function createApp(dependencies: AppDependencies = {}): express.Express { + const app = express(); + + app.use((_req, res, next) => { + inFlightRequests++; + res.on('finish', () => inFlightRequests--); + res.on('close', () => { + if (!res.writableFinished) inFlightRequests--; + }); + next(); + }); + + const apiRouter = express.Router(); + const healthQuery = dependencies.healthQuery ?? dbQuery; + const healthStatus = dependencies.healthStatus ?? dbHealth; + + app.use(requestIdMiddleware()); + app.set("trust proxy", 1); + app.use(createCorsMiddleware() as RequestHandler); + app.use(express.json({ limit: "32kb" })); + app.use(morgan(env.NODE_ENV === "test" ? "tiny" : "dev")); + + app.get("/health", async (_req: Request, res: Response) => { + const db = await healthStatus(); + res.status(db.healthy ? 200 : 503).json({ + status: db.healthy ? "ok" : "degraded", + service: "revora-backend", + db, + }); + }); + + app.get("/health/failover", async (_req: Request, res: Response) => { + const region = process.env.REGION ?? env.REGION; + const activeRegion = process.env.FAILOVER_ACTIVE_REGION ?? env.FAILOVER_ACTIVE_REGION ?? region; + const db = await healthStatus(); + res.status(db.healthy ? 200 : 503).json({ + region, + activeRegion, + isActive: region === activeRegion, + db: db.healthy ? "up" : "down", + failoverActive: region !== activeRegion, + timestamp: new Date().toISOString(), + }); + }); + + app.use("/health", createHealthRouter(healthQuery as any, healthStatus, undefined, env.REGION)); + + apiRouter.get("/overview", (_req: Request, res: Response) => { + res.json({ + name: "Stellar RevenueShare (Revora) Backend", + description: + "Backend API skeleton for tokenized revenue-sharing on Stellar (offerings, investments, revenue distribution).", + version: "0.1.0", + }); + }); + + /** + * @notice Rate-limiter tier policy enforcement for the STARTUP_REGISTER endpoint. + * + * Security assumptions: + * - Tier resolution is performed via the `x-revora-rate-tier` request header. + * - Privileged tiers (`trusted`, `internal`) require a valid shared secret in + * `x-revora-tier-secret`; an absent, empty, or mismatched secret causes + * silent downgrade to the `standard` tier (fail-safe). + * - If no tier header is supplied, the request is treated as `standard`. + * - Rate-limit state is in-process; a distributed store (e.g. Redis) must be + * substituted for multi-instance deployments. + */ + const startupTierLimiter = createStartupAuthTierLimiter(); + apiRouter.post( + "/startup/register", + startupTierLimiter.middleware, + createStartupRegisterHandler(), + ); + + apiRouter.post( + "/offerings/validation-matrix", + requireOfferingAuth, + offeringSanitizeMiddleware, + createOfferingValidationHandler(), + ); + + apiRouter.use("/vesting", vestingRouter); + + // Mount password reset router + app.use(createPasswordResetRouter({ db: pool, emailService })); + + // Initialize email deliverability service (when enabled) + if (env.EMAIL_DELIVERABILITY_ENABLED) { + const emailDeliverabilityRepo = new EmailDeliverabilityRepository(pool); + const emailDeliverabilityService = new EmailDeliverabilityService( + emailDeliverabilityRepo, + new MetricsCollector({ enabled: true }), + { + enabled: env.EMAIL_DELIVERABILITY_ENABLED, + suppressionAutoExpireDays: env.SUPPRESSION_AUTO_EXPIRE_DAYS, + bounceRatioAlarmThreshold: env.BOUNCE_RATIO_ALARM_THRESHOLD, + }, + ); + + // Wire into the existing email service + emailService.setDeliverabilityService(emailDeliverabilityService); + + // Mount email bounce webhook routes + app.use( + '/api/v1/email/webhooks', + createEmailWebhooksRouter(emailDeliverabilityService, { + sendgridWebhookSecret: env.SENDGRID_EVENT_WEBHOOK_SECRET, + }), + ); + } + + // Initialize repositories for admin and audit routes + const auditLogRepo = new AuditLogRepository(pool); + const amlAuditRepo = new InMemorySecurityAuditRepository(); + const retentionLabelService = new RetentionLabelService( + new RetentionLabelRepository(pool), + auditLogRepo, + ); + const tenantSettingsRepo = new TenantSettingsRepository(pool); + const contractUpgradeService = env.STELLAR_SERVER_SECRET + ? new ContractUpgradeOrchestratorService( + pool, + auditLogRepo, + tenantSettingsRepo, + Keypair.fromSecret(env.STELLAR_SERVER_SECRET), + ) + : null; + + // Mount admin router + apiRouter.use("/admin", createAdminRouter(auditLogRepo, retentionLabelService)); + apiRouter.use("/admin", createAdminKycRiskTierRouter(pool, amlAuditRepo)); + + // Mount admin webhook dead-letter routes + const webhookEndpointRepo = new WebhookEndpointRepository(pool); + apiRouter.use("/admin/webhooks", createAdminWebhookRouter({ webhookEndpointRepo })); + + // Mount admin ledger double-entry export (RBAC + audited) + apiRouter.use( + "/admin/ledger", + createAdminLedgerExportRouter({ + distributionAccountRepo: new DistributionRepository(pool), + accountingLedger: new AccountingLedgerService(), + auditLogRepo, + }), + ); + + if (contractUpgradeService) { + apiRouter.use( + "/contract-upgrades", + createContractUpgradeRouter(contractUpgradeService), + ); + } + + // Initialize AML service and routes + const amlService = createAMLService(pool, amlAuditRepo, 'system'); + apiRouter.use("/aml", createAMLRoutes(amlService)); + + // Initialize sanctions list versioning and compliance routes + const sanctionsVersionsRepo = new SanctionsListVersionsRepository(pool); + const sanctionsListDiffService = new SanctionsListDiffService(sanctionsVersionsRepo); + apiRouter.use("/compliance", createComplianceRouter(sanctionsVersionsRepo, sanctionsListDiffService)); + + // Initialize ledger export with in-memory repository + // TODO: Replace with PgLedgerEntryRepository when ledger_entries table exists + const ledgerRepo = new InMemoryLedgerRepository(); + const ledgerExportService = new LedgerExportService(ledgerRepo); + apiRouter.use("/ledger", createLedgerExportRouter(ledgerExportService)); + + // Investor statements (Issue #874): the fetch endpoint re-verifies the + // persisted sha256 before serving. Storage defaults to in-memory — replace + // with the S3-backed adapter when one is deployed so completed renders are + // retrievable across instances. Without a storage adapter, requests simply + // 404 (no artifacts exist), which is fail-safe. + const statementStorage = new InMemoryStatementPdfStorage(); + const pdfRenderJobRepo = new PdfRenderJobRepository(pool); + const sessionRepo = new SessionRepository(pool); + apiRouter.use( + "/statements", + createStatementsRouter({ + jobRepo: pdfRenderJobRepo, + storage: statementStorage, + verifyJWT: createRequireAuth(sessionRepo), + }), + ); + + // Mount taxation routes for per-lot cost-basis tax reporting + app.use(API_VERSION_PREFIX + '/taxation', taxationRouter); + + // Expose reconciliation alarms and discrepancy metrics (OpenMetrics subset). + // Guards alarms via bearer token in production; bypassed in dev/test. + app.get( + "/metrics/reconciliation", + createMetricsAuthMiddleware(), + createReconciliationMetricsHandler(globalMetrics), + ); + + app.use(API_VERSION_PREFIX, apiRouter); + app.use((_req, _res, next) => next(Errors.notFound("Route not found"))); + app.use(errorHandler); + + return app; +} + +export const __test = { + stableSerialize, + parseMoneyString, + parseIsoDate, + parseOfferingValidationPayload, + evaluateOfferingValidationMatrix, + /** + * @dev Exposes the tier-limiter factory for integration tests that need to + * inspect tier resolution or reset counters without restarting the app. + */ + createStartupAuthTierLimiter, + /** + * @dev Exposes the OFAC loader for integration tests. + */ + OfacSanctionsLoader, +}; + +export { classifyStellarRPCFailure, StellarRPCFailureClass }; + +export const app = createApp(); + +let isShuttingDown = false; + +/* istanbul ignore next -- exercised only in real process shutdown */ +async function shutdown(signal: string): Promise { + if (isShuttingDown) return; + isShuttingDown = true; + + globalSampler.stop(); + console.log(`\n[server] ${signal} shutting down`); + + if (server) { + const drainTimeoutMs = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || '10000', 10); + + // Stop accepting new connections + const serverClosePromise = new Promise((resolve, reject) => { + server!.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + + console.log('[server] Stopped accepting new connections. Draining in-flight requests...'); + + const drainStart = Date.now(); + while (inFlightRequests > 0) { + if (Date.now() - drainStart > drainTimeoutMs) { + console.warn(`[server] Drain timeout exceeded with ${inFlightRequests} in-flight requests. Forcing exit.`); + break; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + if (inFlightRequests === 0) { + console.log('[server] All in-flight requests drained.'); + // Wait for server to fully close (e.g., closing idle keep-alive sockets) + try { + const remainingTime = Math.max(0, drainTimeoutMs - (Date.now() - drainStart)); + await Promise.race([ + serverClosePromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), remainingTime)) + ]); + console.log('[server] Listener closed completely.'); + } catch (err) { + console.warn('[server] Listener close timeout or error. Proceeding to close pool.'); + } + } + } + + await closePool(); + /* istanbul ignore next -- process exit is not unit-test friendly */ + process.exit(0); +} + +let server: ReturnType | undefined; + +/* istanbul ignore next -- setter exists for runtime wiring compatibility */ +export const setServer = (value: ReturnType) => { + server = value; +}; + +/** + * Webhook delivery queue with exponential backoff, SSRF-aware URL blocking, + * bounded depth, and back-pressure via deferred persistence. + * + * @notice When in-flight count reaches WEBHOOK_QUEUE_MAX_DEPTH the delivery is + * persisted as 'deferred' (never dropped) and webhook_queue_shed_total + * is incremented. Call resumeDeferred() to re-enqueue them once capacity + * is available. Shedding is idempotent per delivery: re-enqueueing an + * already-deferred row at capacity reuses the row and does not + * double-count the metric. + */ +export class WebhookQueue { + private static repo: WebhookEndpointRepository; + private static service: WebhookService; + private static MAX_RETRIES = 5; + private static INITIAL_DELAY = 1000; + /** Number of deliveries currently scheduled / in-flight. */ + private static inFlight = 0; + + static init(repo: WebhookEndpointRepository, service: WebhookService) { + this.repo = repo; + this.service = service; + } + + private static get maxDepth(): number { + return env.WEBHOOK_QUEUE_MAX_DEPTH; + } + + private static async isSafeUrl(url: string): Promise { + try { + const result = await validateWebhookUrl(url, true); + if (!result.valid) { + console.error( + `[Security] SSRF validation failed for ${url}: ${result.error?.message}`, + ); + } + return result.valid; + } catch (error) { + console.error(`[Security] Error validating webhook URL ${url}:`, error); + return false; + } + } + + static getBackoffDelay(retryCount: number): number { + if (retryCount >= this.MAX_RETRIES) return -1; + return this.INITIAL_DELAY * Math.pow(2, retryCount); + } + + /** + * Count a shed delivery. Invoked only when a delivery first transitions to + * deferred so the counter is idempotent across retries of the same row. + */ + private static recordShed(endpointId: string): void { + globalMetrics.incrementCounter( + WEBHOOK_QUEUE_SHED_TOTAL, + { endpoint: endpointId }, + 1, + 'Total webhook deliveries deferred due to queue depth limit', + ); + } + + /** + * Attempt delivery of a webhook payload to an active endpoint. + * + * @dev Back-pressure contract: when the bounded queue is at capacity the + * delivery is persisted with status 'deferred' (never dropped) and the + * webhook_queue_shed_total counter is incremented exactly once per + * status transition. When a retry is re-enqueued with `deliveryId`, the + * same row is deferred instead of a duplicate being inserted, so retries + * are idempotent and preserve the attempt counter. + * @param url Webhook endpoint URL (SSRF validation precedes any write) + * @param payload Event payload to deliver + * @param deliveryId Existing delivery row to reuse (retry path) + * @returns true when delivered, false when deferred/failed/absent endpoint + */ + static async processDelivery( + url: string, + payload: any, + deliveryId?: string, + ): Promise { + if (!this.repo || !this.service) { + console.error("[WebhookQueue] Not initialized"); + return false; + } + + if (!(await this.isSafeUrl(url))) { + console.error(`[Security] Blocked unsafe webhook URL: ${url}`); + return false; + } + + const endpoint = await this.repo.findByUrl(url); + if (!endpoint) { + console.error(`[WebhookQueue] No active endpoint found for URL: ${url}`); + return false; + } + + // --- Back-pressure: defer when at capacity --- + if (this.inFlight >= this.maxDepth) { + // When a retry is re-enqueued (deliveryId known), defer that same row + // instead of inserting a duplicate so attempts/backoff state are kept and + // the shed counter stays idempotent across retries of the same delivery. + let deferred: WebhookDelivery | null = deliveryId + ? await this.repo.findDeliveryById(deliveryId) + : null; + + let deferredId: string; + if (!deferred) { + deferred = await this.repo.createDelivery({ + endpoint_id: endpoint.id, + payload, + status: 'deferred', + attempts: 0, + }); + deferredId = deferred.id; + this.recordShed(endpoint.id); + } else { + deferredId = deferred.id; + if (deferred.status !== 'deferred') { + await this.repo.updateDelivery(deferred.id, { + status: 'deferred', + }); + this.recordShed(endpoint.id); + } + } + + globalMetrics.setGauge( + WEBHOOK_QUEUE_DEPTH_GAUGE, + this.inFlight, + {}, + 'Current in-flight webhook deliveries when the queue sheds a delivery', + ); + console.warn( + `[WebhookQueue] Queue full (${this.inFlight}/${this.maxDepth}), deferred delivery ${deferredId}`, + ); + return false; + } + + let delivery: WebhookDelivery | null = null; + if (deliveryId) delivery = await this.repo.findDeliveryById(deliveryId); + + if (!delivery) { + delivery = await this.repo.createDelivery({ + endpoint_id: endpoint.id, + payload, + status: "pending", + attempts: 0, + }); + } + + this.inFlight++; + try { + return await this._attempt(endpoint, delivery, payload); + } finally { + this.inFlight--; + } + } + + private static async _attempt( + endpoint: { id: string; url: string; secret: string }, + delivery: WebhookDelivery, + payload: any, + ): Promise { + const currentAttempt = delivery.attempts + 1; + + // Propagate the transactional outbox event_id when present (idempotency key + // the receiver's webhookEventOrdering relies on to deduplicate retries), and + // fall back to the delivery row id for legacy callers. + const webhookPayload: WebhookPayload = { + id: (payload?.id as string) || delivery.id, + event: (payload as any).event || WebhookEventType.OFFERING_UPDATED, + payload: (payload as any).payload || payload, + timestamp: new Date().toISOString(), + }; + + const result = await this.service.sendAttempt( + { id: endpoint.id, url: endpoint.url, secret: endpoint.secret }, + webhookPayload, + ); + + if (result.success) { + await this.repo.updateDelivery(delivery.id, { + status: "completed", + attempts: currentAttempt, + last_error: null, + next_retry_at: null, + }); + return true; + } + + const isRetryable = + !result.statusCode || + result.statusCode >= 500 || + result.statusCode === 429; + const nextDelay = this.getBackoffDelay(currentAttempt); + + if (isRetryable && nextDelay !== -1) { + const nextRetryAt = new Date(Date.now() + nextDelay); + await this.repo.updateDelivery(delivery.id, { + attempts: currentAttempt, + last_error: result.error, + next_retry_at: nextRetryAt, + }); + + setTimeout(() => { + void this.processDelivery(endpoint.url, payload, delivery.id); + }, nextDelay); + + return false; + } + + await this.repo.updateDelivery(delivery.id, { + status: nextDelay === -1 ? "dead_letter" : "failed", + attempts: currentAttempt, + last_error: result.error, + next_retry_at: null, + }); + + if (nextDelay === -1) { + try { + const count = await this.repo.countDeadLettersByEndpoint(delivery.endpoint_id); + globalMetrics.setGauge( + 'webhook_dead_letter_total', + count, + { endpoint: endpoint.id }, + 'Number of dead-lettered webhook deliveries per endpoint', + ); + } catch (err) { + console.error('[WebhookQueue] Failed to update dead-letter metric:', err); + } + } + return false; + } + + static async resumePending(): Promise { + if (!this.repo) return; + const pending = await this.repo.getPendingDeliveries(); + for (const delivery of pending) { + // Honour the same bounded-depth contract as resumeDeferred; excess rows + // stay pending and are picked up on the next resume cycle. + if (this.inFlight >= this.maxDepth) break; + const endpoint = await this.repo.findById(delivery.endpoint_id); + if (endpoint) { + void this.processDelivery(endpoint.url, delivery.payload, delivery.id); + } + } + } + + /** + * Re-enqueue deferred deliveries up to available capacity. + * Safe to call repeatedly; excess deferred rows remain deferred. + */ + static async resumeDeferred(): Promise { + if (!this.repo) return; + const deferred = await this.repo.getDeferredDeliveries(); + for (const delivery of deferred) { + if (this.inFlight >= this.maxDepth) break; + const endpoint = await this.repo.findById(delivery.endpoint_id); + if (!endpoint) continue; + // Promote back to pending so processDelivery can pick it up + await this.repo.updateDelivery(delivery.id, { status: 'pending' }); + void this.processDelivery(endpoint.url, delivery.payload, delivery.id); + } + } +} + +/* istanbul ignore next -- bootstrapping is integration-environment specific */ +if (require.main === module && env.NODE_ENV !== "test") { + process.on("SIGTERM", () => { + void shutdown("SIGTERM"); + }); + process.on("SIGINT", () => { + void shutdown("SIGINT"); + }); + + const backgroundStopFns: (() => void)[] = []; + + // Resolve worker role — fail-fast on invalid value + const { resolveWorkerRole, getRoleConfig } = require("./config/workerRole"); + const workerRole = resolveWorkerRole(env.ROLE, env.NODE_ENV); + const roleConfig = getRoleConfig(workerRole); + console.log(`[server] Starting with role="${workerRole}"`, roleConfig); + + const metricsCollector = new MetricsCollector(); + + const payoutDriftRepo = new PayoutDriftRepository(pool); + const payoutDriftDetector = new PayoutDriftDetector( + pool, + payoutDriftRepo, + metricsCollector + ); + + payoutDriftDetector.start(); // Start nightly payout drift detection + globalSampler.start(); // Start event loop lag monitoring + + if (roleConfig.auditPurge) { + const auditLogRepo = new AuditLogRepository(pool); + const auditPurgeService = new AuditPurgeService(auditLogRepo, metricsCollector); + auditPurgeService.start(); + backgroundStopFns.push(() => auditPurgeService.stop()); + console.log("[server] AuditPurgeService started"); + } + + if (roleConfig.auditPurge) { // Reusing auditPurge role for general cleanup tasks + const sessionRepo = new SessionRepository(pool); + const sessionCompactionService = new SessionCompactionService(sessionRepo, metricsCollector); + sessionCompactionService.start(); + backgroundStopFns.push(() => sessionCompactionService.stop()); + console.log("[server] SessionCompactionService started"); + } + + if (roleConfig.payoutDrift) { + const payoutDriftRepo = new PayoutDriftRepository(pool); + const payoutDriftDetector = new PayoutDriftDetector( + pool, + payoutDriftRepo, + metricsCollector, + ); + payoutDriftDetector.start(); + backgroundStopFns.push(() => payoutDriftDetector.stop()); + console.log("[server] PayoutDriftDetector started"); + } + + if (roleConfig.reconciliation) { + const reconciliationScheduler = createReconciliationSchedulerRuntime({ + db: pool, + metrics: globalMetrics, + logger: undefined, + }); + reconciliationScheduler.start(); + backgroundStopFns.push(() => reconciliationScheduler.stop()); + console.log("[server] ReconciliationScheduler started"); + } + + // --- Hot-path services (only for "api" and "all" roles) --- + + if (roleConfig.webhookQueue) { + const repo = new WebhookEndpointRepository(pool); + const service = new WebhookService(repo, { + outboxRepo: env.OUTBOX_DISPATCHER_ENABLED ? new OutboxRepository(pool) : undefined, + }); + WebhookQueue.init(repo, service); + void WebhookQueue.resumePending(); + console.log("[server] WebhookQueue started"); + + // Drain the transactional outbox in a separate polling worker. Every + // outbox row was written atomically with the transaction that produced + // the event; retries reuse the same event_id so receivers can deduplicate + // via webhookEventOrdering (exactly-once). + if (env.OUTBOX_DISPATCHER_ENABLED) { + const outboxRepo = new OutboxRepository(pool); + const dispatcher = new OutboxDispatcher( + outboxRepo, + makeWebhookDispatchFn( + WebhookQueue.processDelivery.bind(WebhookQueue), + (event) => repo.listActiveByEvent(event), + ), + ); + dispatcher.start(); + backgroundStopFns.push(() => dispatcher.stop()); + console.log("[server] OutboxDispatcher started"); + } + } + + for (const stopFn of backgroundStopFns) { + process.on("SIGTERM", stopFn); + process.on("SIGINT", stopFn); + } + + // --- HTTP server (only for "api" and "all" roles) --- + + if (roleConfig.httpServer) { + server = app.listen(port, () => { + console.log(`revora-backend listening on http://localhost:${port} (role=${workerRole})`); + }); + } else { + console.log(`[server] HTTP server disabled for role="${workerRole}". Running background workers only.`); + } +} + +export default app; +// Rate limiter tier policies implementation + \ No newline at end of file diff --git a/src/routes/health.test.ts b/src/routes/health.test.ts index 86400bc8..3ac3e206 100644 --- a/src/routes/health.test.ts +++ b/src/routes/health.test.ts @@ -1,1670 +1,1672 @@ -import express from "express"; -import request from "supertest"; -import { - __test, - classifyStellarRPCFailure, - createApp, - StellarRPCFailureClass, - WebhookQueue, -} from '../index'; -import { closePool } from '../db/client'; -import { ErrorCode } from '../lib/errors'; -import { MetricsCollector } from '../lib/metrics'; -import { - calculateLag, - calculateUptimeSeconds, - createHealthRouter, - healthLiveHandler, - healthReadyHandler, - healthRegionHandler, - healthRootHandler, - healthStartupHandler, - mapHealthDependencyFailure, - HealthDependencyGraph, - DependencyHealth, -} from "./health"; -import { - STARTUP_AUTH_RATE_TIER_HEADER, - STARTUP_AUTH_TIER_SECRET_HEADER, - STARTUP_AUTH_RATE_TIER_POLICIES, -} from "../middleware/startupAuthRateTierPolicy"; - -afterAll(async () => { - await closePool(); -}); - -describe("classifyStellarRPCFailure", () => { - it("classifies timeout failures", () => { - const error = new Error("network timeout"); - error.name = "AbortError"; - - expect(classifyStellarRPCFailure(error).class).toBe( - StellarRPCFailureClass.TIMEOUT, - ); - }); - - it("classifies rate limit failures", () => { - expect(classifyStellarRPCFailure({ status: 429 }).class).toBe( - StellarRPCFailureClass.RATE_LIMIT, - ); - }); - - it("classifies auth failures", () => { - expect(classifyStellarRPCFailure({ status: 401 }).class).toBe( - StellarRPCFailureClass.UNAUTHORIZED, - ); - expect(classifyStellarRPCFailure({ status: 403 }).class).toBe( - StellarRPCFailureClass.UNAUTHORIZED, - ); - }); - - it("classifies upstream 5xx failures", () => { - expect(classifyStellarRPCFailure({ status: 503 }).class).toBe( - StellarRPCFailureClass.UPSTREAM_ERROR, - ); - }); - - it("classifies malformed responses", () => { - expect(classifyStellarRPCFailure(new SyntaxError("bad json")).class).toBe( - StellarRPCFailureClass.MALFORMED_RESPONSE, - ); - }); - - it("falls back to unknown for uncategorized errors", () => { - expect(classifyStellarRPCFailure(new Error("something odd")).class).toBe( - StellarRPCFailureClass.UNKNOWN, - ); - }); -}); - -describe("mapHealthDependencyFailure", () => { - it("sanitizes database dependency errors", () => { - const mapped = mapHealthDependencyFailure( - "database", - new Error("password auth failed"), - ); - - expect(mapped.toResponse()).toEqual({ - code: ErrorCode.SERVICE_UNAVAILABLE, - message: "Dependency unavailable", - details: { - dependency: "database", - }, - }); - }); - - it("preserves stable Stellar metadata without leaking raw upstream details", () => { - const mapped = mapHealthDependencyFailure("stellar-horizon", { - status: 503, - }); - - expect(mapped.toResponse()).toEqual({ - code: ErrorCode.SERVICE_UNAVAILABLE, - message: "Dependency unavailable", - details: { - dependency: "stellar-horizon", - failureClass: StellarRPCFailureClass.UPSTREAM_ERROR, - upstreamStatus: 503, - }, - }); - }); -}); - -describe("healthReadyHandler", () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it("returns ok when both database and horizon are healthy", async () => { - const mockDb = { - query: jest.fn().mockResolvedValue({ rows: [{ "?column?": 1 }] }), - }; - global.fetch = jest - .fn() - .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/ready", healthReadyHandler(mockDb as any)); - - const response = await request(app).get("/ready"); - - expect(response.status).toBe(200); - expect(response.body.ready).toBe(true); - expect(response.body.status).toBe("ok"); - expect(response.body.db).toBe("up"); - expect(response.body.stellar).toBe("up"); - expect(response.body.service).toBe("revora-backend"); - }); - - it("surfaces sanitized database failures", async () => { - const mockDb = { - query: jest.fn().mockRejectedValue(new Error("connection failed")), - }; - global.fetch = jest - .fn() - .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/ready", healthReadyHandler(mockDb as any)); - app.use( - ( - err: any, - _req: express.Request, - res: express.Response, - _next: express.NextFunction, - ) => { - const statusCode = err.statusCode || 500; - const body = typeof err.toResponse === 'function' ? err.toResponse() : { message: err.message }; - res.status(statusCode).json(body); - }, - ); - - const response = await request(app).get("/ready"); - - expect(response.status).toBe(503); - expect(response.body).toEqual({ - code: ErrorCode.SERVICE_UNAVAILABLE, - message: "Dependency unavailable", - details: { - dependency: "database", - }, - }); - }); - - it("maps Stellar upstream failures deterministically", async () => { - const mockDb = { - query: jest.fn().mockResolvedValue({ rows: [{ "?column?": 1 }] }), - }; - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 20, - pool: { - totalCount: 2, - idleCount: 2, - waitingCount: 0, - maxConnections: 10, - }, - }); - global.fetch = jest - .fn() - .mockResolvedValue({ ok: false, status: 429 }) as typeof fetch; - - const app = express(); - app.use("/health", createHealthRouter(mockDb as any, mockDbHealth)); - app.use( - ( - err: any, - _req: express.Request, - res: express.Response, - _next: express.NextFunction, - ) => { - const statusCode = err.statusCode || 500; - const body = typeof err.toResponse === 'function' ? err.toResponse() : { message: err.message }; - res.status(statusCode).json(body); - }, - ); - - const response = await request(app).get("/health/ready"); - - expect(response.status).toBe(503); - expect(response.body).toEqual({ - code: ErrorCode.SERVICE_UNAVAILABLE, - message: "Dependency unavailable", - details: { - dependency: "stellar-horizon", - failureClass: StellarRPCFailureClass.RATE_LIMIT, - upstreamStatus: 429, - }, - }); - }); - - it('catches and surfaces fetch exceptions deterministically', async () => { - const db = { query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }) }; - const networkError = new Error('network timeout'); - networkError.name = 'AbortError'; - global.fetch = jest.fn().mockRejectedValue(networkError) as typeof fetch; - - const app = express(); - const mockDbHealth = jest.fn().mockResolvedValue({ healthy: true }); - app.use('/health', createHealthRouter(db as any, mockDbHealth)); - app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - const mapped = err as { statusCode: number; toResponse: () => unknown }; - res.status(mapped.statusCode).json(mapped.toResponse()); - }); - - const response = await request(app).get('/health/ready'); - - expect(response.status).toBe(503); - expect(response.body).toEqual({ - code: ErrorCode.SERVICE_UNAVAILABLE, - message: 'Dependency unavailable', - details: { - dependency: 'stellar-horizon', - failureClass: StellarRPCFailureClass.TIMEOUT, - }, - }); - }); -}); - -describe("offering validation matrix", () => { - const path = "/api/v1/offerings/validation-matrix"; - - function buildApp() { - return createApp({ - healthStatus: jest - .fn() - .mockResolvedValue({ healthy: true, latencyMs: 1 }), - healthQuery: jest.fn(), - }); - } - - function authHeaders(role: string, id = "actor-1") { - return { - "x-user-id": id, - "x-user-role": role, - }; - } - - it("rejects unauthenticated callers at the auth boundary", async () => { - const response = await request(buildApp()) - .post(path) - .send({ - action: "create", - offering: { targetAmount: "1000.00", minimumInvestment: "50.00" }, - }); - - expect(response.status).toBe(401); - expect(response.body).toMatchObject({ - code: ErrorCode.UNAUTHORIZED, - message: "Offering validation requires x-user-id and x-user-role headers", - }); - }); - - it("rejects invalid actions with an explicit schema error", async () => { - const response = await request(buildApp()) - .post(path) - .set(authHeaders("startup")) - .send({ - action: "destroy", - offering: {}, - }); - - expect(response.status).toBe(400); - expect(response.body).toMatchObject({ - code: ErrorCode.BAD_REQUEST, - message: "Invalid offering validation action", - }); - }); - - it("allows a startup to publish its own valid draft offering", async () => { - const response = await request(buildApp()) - .post(path) - .set(authHeaders("startup", "issuer-1")) - .send({ - action: "publish", - offering: { - id: "off-1", - issuerId: "issuer-1", - status: "draft", - targetAmount: "1000.00", - minimumInvestment: "50.00", - subscriptionStartsAt: "2030-01-01T00:00:00.000Z", - subscriptionEndsAt: "2030-01-15T00:00:00.000Z", - }, - }); - - expect(response.status).toBe(200); - expect(response.body.allowed).toBe(true); - expect(response.body.decision).toBe("allow"); - expect(response.body.violations).toEqual([]); - }); - - it("denies a startup from managing another issuers offering", async () => { - const response = await request(buildApp()) - .post(path) - .set(authHeaders("startup", "issuer-1")) - .send({ - action: "pause", - offering: { - id: "off-2", - issuerId: "issuer-2", - status: "open", - }, - }); - - expect(response.status).toBe(422); - expect(response.body.allowed).toBe(false); - expect(response.body.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "OWNERSHIP_CONFIRMED", - }), - ]), - ); - }); - - it("denies investment attempts outside the allowed subscription window", async () => { - const response = await request(buildApp()) - .post(path) - .set(authHeaders("investor", "investor-1")) - .send({ - action: "invest", - offering: { - id: "off-3", - issuerId: "issuer-3", - status: "open", - targetAmount: "1000.00", - minimumInvestment: "100.00", - investmentAmount: "125.00", - subscriptionStartsAt: "2020-01-01T00:00:00.000Z", - subscriptionEndsAt: "2020-01-15T00:00:00.000Z", - }, - }); - - expect(response.status).toBe(422); - expect(response.body.allowed).toBe(false); - expect(response.body.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "INVESTMENT_WINDOW_ACTIVE", - }), - ]), - ); - }); - - it("blocks issuer self-investment by default", async () => { - const response = await request(buildApp()) - .post(path) - .set(authHeaders("investor", "issuer-4")) - .send({ - action: "invest", - offering: { - id: "off-4", - issuerId: "issuer-4", - status: "open", - targetAmount: "500.00", - minimumInvestment: "50.00", - investmentAmount: "50.00", - subscriptionStartsAt: "2030-01-01T00:00:00.000Z", - subscriptionEndsAt: "2030-01-10T00:00:00.000Z", - }, - }); - - expect(response.status).toBe(422); - expect(response.body.allowed).toBe(false); - expect(response.body.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "INVESTOR_NOT_ISSUER", - }), - ]), - ); - }); - - it("allows privileged compliance actors to review private offerings without ownership", async () => { - const response = await request(buildApp()) - .post(path) - .set(authHeaders("compliance", "compliance-1")) - .send({ - action: "viewPrivate", - offering: { - id: "off-5", - issuerId: "issuer-99", - status: "paused", - }, - }); - - expect(response.status).toBe(200); - expect(response.body.allowed).toBe(true); - expect(response.body.violations).toEqual([]); - }); - - it("returns degraded root health when the dependency checker reports failure", async () => { - const app = createApp({ - healthStatus: jest.fn().mockResolvedValue({ - healthy: false, - latencyMs: 4, - error: "sanitized-db-error", - }), - healthQuery: jest.fn(), - }); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(503); - expect(response.body).toEqual({ - status: "degraded", - service: "revora-backend", - db: { - healthy: false, - latencyMs: 4, - error: "sanitized-db-error", - }, - }); - }); - - it("serves the overview document on the versioned API prefix", async () => { - const response = await request(buildApp()).get("/api/v1/overview"); - - expect(response.status).toBe(200); - expect(response.body).toMatchObject({ - name: "Stellar RevenueShare (Revora) Backend", - version: "0.1.0", - }); - }); - - it("applies multi-tier rate limiting for startup registration", async () => { - const SECRET = "test-tier-secret"; - process.env.STARTUP_AUTH_TIER_SECRET = SECRET; - const path = "/api/v1/startup/register"; - - // 1. Standard Tier (Default: 5 requests) - { - const app = buildApp(); - for (let i = 0; i < 5; i++) { - const res = await request(app).post(path).send({ email: `std-${i}@test.com`, password: "Pass" }); - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-limit"]).toBe("5"); - } - const blockedStd = await request(app).post(path).send({ email: "std-fail@test.com", password: "Pass" }); - expect(blockedStd.status).toBe(429); - } - - // 2. Trusted Tier (10 requests) - { - const app = buildApp(); - const trustedHeaders = { "x-revora-rate-tier": "trusted", "x-revora-tier-secret": SECRET }; - for (let i = 0; i < 10; i++) { - const res = await request(app).post(path).set(trustedHeaders).send({ email: `trust-${i}@test.com`, password: "Pass" }); - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-limit"]).toBe("10"); - } - const blockedTrust = await request(app).post(path).set(trustedHeaders).send({ email: "trust-fail@test.com", password: "Pass" }); - expect(blockedTrust.status).toBe(429); - } - - // 3. Internal Tier (25 requests) - { - const app = buildApp(); - const internalHeaders = { "x-revora-rate-tier": "internal", "x-revora-tier-secret": SECRET }; - for (let i = 0; i < 25; i++) { - const res = await request(app).post(path).set(internalHeaders).send({ email: `int-${i}@test.com`, password: "Pass" }); - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-limit"]).toBe("25"); - } - const blockedInt = await request(app).post(path).set(internalHeaders).send({ email: "int-fail@test.com", password: "Pass" }); - expect(blockedInt.status).toBe(429); - } - - // 4. Invalid Secret Fallback (Standard Tier) - { - const app = buildApp(); - const invalidHeaders = { "x-revora-rate-tier": "internal", "x-revora-tier-secret": "wrong" }; - for (let i = 0; i < 5; i++) { - const res = await request(app).post(path).set(invalidHeaders).send({ email: `wrong-${i}@test.com`, password: "Pass" }); - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-limit"]).toBe("5"); - } - const blockedWrong = await request(app).post(path).set(invalidHeaders).send({ email: "wrong-fail@test.com", password: "Pass" }); - expect(blockedWrong.status).toBe(429); - } - }); - - it("rejects startup registration payloads that omit required credentials", async () => { - const response = await request(buildApp()) - .post("/api/v1/startup/register") - .send({ email: "missing-password@example.com" }); - - expect(response.status).toBe(400); - expect(response.body).toEqual({ - error: "Email and password are required", - }); - }); -}); - -describe("WebhookQueue", () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.spyOn(console, "error").mockImplementation(() => undefined); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - }); - - it("classifies safe and unsafe webhook targets correctly", async () => { - const isSafeUrl = ( - WebhookQueue as unknown as { isSafeUrl: (url: string) => Promise } - ).isSafeUrl; - - expect(await isSafeUrl("https://example.com/hooks")).toBe(true); - expect(await isSafeUrl("http://127.0.0.1")).toBe(false); - expect(await isSafeUrl("http://localhost")).toBe(false); - expect(await isSafeUrl("not-a-valid-url")).toBe(false); - }); - - it("uses exponential backoff and stops after the configured retry ceiling", async () => { - const deliveryPromise = WebhookQueue.processDelivery( - "https://example.com/hooks", - { - event: "test", - }, - ); - - await jest.advanceTimersByTimeAsync(31_000); - - await expect(deliveryPromise).resolves.toBe(false); - expect(WebhookQueue.getBackoffDelay(0)).toBe(1000); - expect(WebhookQueue.getBackoffDelay(5)).toBe(-1); - }); - - it("fails fast for unsafe SSRF-style destinations", async () => { - await expect( - WebhookQueue.processDelivery("http://192.168.1.10/internal", { - event: "test", - }), - ).resolves.toBe(false); - }); -}); - -describe('health metrics collection', () => { - let metrics: MetricsCollector; - - beforeEach(() => { - metrics = new MetricsCollector({ enabled: true }); - }); - - afterEach(() => { - metrics.reset(); - }); - - it('should record successful health check metrics', async () => { - const db = { - query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), - }; - global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get('/ready', healthReadyHandler(db, metrics)); - app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - const mapped = err as { statusCode: number; toResponse: () => unknown }; - res.status(mapped.statusCode).json(mapped.toResponse()); - }); - - await request(app).get('/ready'); - - const snapshot = await metrics.getSnapshot(); - expect(snapshot.custom.length).toBeGreaterThan(0); - - // Check for health check metrics - const dbSuccess = snapshot.custom.find(m => - m.name === 'health_checks_total' && - m.labels?.check === 'database' && - m.labels?.status === 'success' - ); - expect(dbSuccess?.value).toBe(1); - - const stellarSuccess = snapshot.custom.find(m => - m.name === 'health_checks_total' && - m.labels?.check === 'stellar-horizon' && - m.labels?.status === 'success' - ); - expect(stellarSuccess?.value).toBe(1); - }); - - it('should record failed health check metrics', async () => { - const db = { - query: jest.fn().mockRejectedValue(new Error('connection failed')), - }; - global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get('/ready', healthReadyHandler(db, metrics)); - app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - const mapped = err as { statusCode: number; toResponse: () => unknown }; - res.status(mapped.statusCode).json(mapped.toResponse()); - }); - - await request(app).get('/ready'); - - const snapshot = await metrics.getSnapshot(); - const dbFailure = snapshot.custom.find(m => - m.name === 'health_checks_total' && - m.labels?.check === 'database' && - m.labels?.status === 'failure' - ); - expect(dbFailure?.value).toBe(1); - }); - - it('should record health check duration', async () => { - const db = { - query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), - }; - global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get('/ready', healthReadyHandler(db, metrics)); - - await request(app).get('/ready'); - - const snapshot = await metrics.getSnapshot(); - const durationMetric = snapshot.custom.find(m => - m.name === 'health_check_duration_ms' && - m.labels?.endpoint === 'ready' - ); - expect(durationMetric).toBeDefined(); - expect(durationMetric?.value).toBeGreaterThanOrEqual(0); - }); - - it('should work without metrics collector', async () => { - const db = { - query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), - }; - global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get('/ready', healthReadyHandler(db)); // No metrics - - const response = await request(app).get('/ready'); - - expect(response.status).toBe(200); - expect(response.body).toMatchObject({ - status: 'ok', - db: 'up', - stellar: 'up', - ready: true, - service: 'revora-backend', - }); - }); -}); - -describe('__test helpers', () => { - it('stableSerialize sorts object keys recursively', () => { - expect( - __test.stableSerialize({ - b: 1, - a: { d: 4, c: 3 }, - }), - ).toBe('{"a":{"c":3,"d":4},"b":1}'); - }); - - it("stableSerialize preserves arrays while sorting nested object keys", () => { - expect( - __test.stableSerialize([ - { z: 2, a: 1 }, - { b: 2, a: 1 }, - ]), - ).toBe('[{"a":1,"z":2},{"a":1,"b":2}]'); - }); - - it("parseMoneyString accepts bounded decimal strings and rejects invalid input", () => { - expect(__test.parseMoneyString("999.99")).toBe(999.99); - expect(__test.parseMoneyString("1e6")).toBeNull(); - expect(__test.parseMoneyString(10)).toBeNull(); - }); - - it("parseIsoDate accepts valid ISO strings and rejects invalid dates", () => { - expect(__test.parseIsoDate("2030-01-01T00:00:00.000Z")?.toISOString()).toBe( - "2030-01-01T00:00:00.000Z", - ); - expect(__test.parseIsoDate("definitely-not-a-date")).toBeNull(); - }); - - it("parseOfferingValidationPayload preserves trimmed deterministic values", () => { - expect( - __test.parseOfferingValidationPayload({ - action: "create", - offering: { - issuerId: " issuer-1 ", - targetAmount: "100.00", - minimumInvestment: "10.00", - }, - }), - ).toEqual({ - action: "create", - offering: { - issuerId: "issuer-1", - targetAmount: "100.00", - minimumInvestment: "10.00", - }, - }); - }); - - it("parseOfferingValidationPayload rejects malformed bodies and invalid field values", () => { - expect(() => __test.parseOfferingValidationPayload(null)).toThrow( - "Validation payload must be a JSON object", - ); - expect(() => - __test.parseOfferingValidationPayload({ - action: "create", - }), - ).toThrow("Offering validation payload must include an offering object"); - expect(() => - __test.parseOfferingValidationPayload({ - action: "create", - offering: { id: " " }, - }), - ).toThrow("offering.id must be a non-empty string"); - expect(() => - __test.parseOfferingValidationPayload({ - action: "create", - offering: { issuerId: "" }, - }), - ).toThrow("offering.issuerId must be a non-empty string"); - expect(() => - __test.parseOfferingValidationPayload({ - action: "create", - offering: { status: "live" }, - }), - ).toThrow("offering.status must be a supported offering status"); - expect(() => - __test.parseOfferingValidationPayload({ - action: "create", - offering: { targetAmount: "" }, - }), - ).toThrow("offering.targetAmount must be a non-empty string"); - }); - - it("evaluateOfferingValidationMatrix covers close, cancel, and missing investment window rules", () => { - const actor = { id: "issuer-1", role: "startup" as const }; - - const closeResult = __test.evaluateOfferingValidationMatrix(actor, { - action: "close", - offering: { - issuerId: "issuer-1", - status: "paused", - }, - }); - expect(closeResult.allowed).toBe(true); - - const cancelResult = __test.evaluateOfferingValidationMatrix(actor, { - action: "cancel", - offering: { - issuerId: "issuer-1", - status: "closed", - }, - }); - expect(cancelResult.allowed).toBe(false); - expect(cancelResult.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ code: "STATUS_ELIGIBLE_FOR_CANCEL" }), - ]), - ); - - const investResult = __test.evaluateOfferingValidationMatrix( - { id: "investor-1", role: "investor" }, - { - action: "invest", - offering: { - issuerId: "issuer-2", - status: "open", - targetAmount: "1000.00", - minimumInvestment: "50.00", - investmentAmount: "50.00", - }, - }, - new Date("2030-01-05T00:00:00.000Z"), - ); - - expect(investResult.allowed).toBe(false); - expect(investResult.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ code: "INVESTMENT_WINDOW_ACTIVE" }), - ]), - ); - }); -}); - -describe("healthRootHandler - dependency graph", () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it("returns comprehensive health with dependency graph when all services are healthy", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 25, - pool: { - totalCount: 5, - idleCount: 3, - waitingCount: 0, - maxConnections: 10, - }, - }); - global.fetch = jest - .fn() - .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(200); - expect(response.body.status).toBe("healthy"); - expect(response.body.service).toBe("revora-backend"); - expect(response.body.checks).toHaveLength(2); - expect(response.body.checks[0].name).toBe("database"); - expect(response.body.checks[0].status).toBe("up"); - expect(response.body.checks[0].healthy).toBe(true); - expect(response.body.checks[0].details).toMatchObject({ - totalCount: 5, - idleCount: 3, - utilizationPercent: 50, - }); - expect(response.body.checks[1].name).toBe("stellar-horizon"); - expect(response.body.checks[1].status).toBe("up"); - expect(response.body.checks[1].healthy).toBe(true); - expect(response.body.uptime).toBeGreaterThanOrEqual(0); - expect(response.body.timestamp).toBeDefined(); - expect(response.body.version).toBeDefined(); - }); - - it("returns degraded status when pool utilization is high", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 50, - pool: { - totalCount: 9, - idleCount: 1, - waitingCount: 2, - maxConnections: 10, - }, - }); - global.fetch = jest - .fn() - .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(200); - expect(response.body.status).toBe("degraded"); - expect(response.body.checks[0].status).toBe("degraded"); - expect(response.body.checks[0].details.utilizationPercent).toBe(90); - }); - - it("returns unhealthy status and 503 when database is down", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: false, - latencyMs: 100, - error: "connection refused", - pool: { - totalCount: 0, - idleCount: 0, - waitingCount: 0, - maxConnections: 10, - }, - }); - global.fetch = jest - .fn() - .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(503); - expect(response.body.status).toBe("unhealthy"); - expect(response.body.checks[0].name).toBe("database"); - expect(response.body.checks[0].status).toBe("down"); - expect(response.body.checks[0].healthy).toBe(false); - expect(response.body.checks[0].error).toBe("sanitized-db-error"); - }); - - it("includes requestId in response when provided in headers", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 10, - pool: { - totalCount: 2, - idleCount: 2, - waitingCount: 0, - maxConnections: 10, - }, - }); - global.fetch = jest - .fn() - .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app) - .get("/health") - .set("x-request-id", "test-req-123"); - - expect(response.body.requestId).toBe("test-req-123"); - }); -}); - -describe("healthLiveHandler - liveness probe", () => { - it("returns alive status for liveness probe", async () => { - const app = express(); - app.get("/live", healthLiveHandler()); - - const response = await request(app).get("/live"); - - expect(response.status).toBe(200); - expect(response.body.alive).toBe(true); - expect(response.body.service).toBe("revora-backend"); - expect(response.body.timestamp).toBeDefined(); - expect(response.body.uptime).toBeGreaterThanOrEqual(0); - }); - - it("includes requestId in liveness response when provided", async () => { - const app = express(); - app.get("/live", healthLiveHandler()); - - const response = await request(app) - .get("/live") - .set("x-request-id", "live-req-456"); - - expect(response.body.requestId).toBe("live-req-456"); - }); -}); - -describe("healthStartupHandler - startup probe", () => { - it("returns ready when database is healthy", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 15, - pool: { - totalCount: 3, - idleCount: 2, - waitingCount: 0, - maxConnections: 10, - }, - }); - - const app = express(); - app.get("/startup", healthStartupHandler(mockDbHealth)); - - const response = await request(app).get("/startup"); - - expect(response.status).toBe(200); - expect(response.body.ready).toBe(true); - expect(response.body.service).toBe("revora-backend"); - expect(response.body.check).toBe("database"); - }); - - it("returns 503 when database is not ready during startup", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: false, - latencyMs: 5000, - error: "timeout", - }); - - const app = express(); - app.get("/startup", healthStartupHandler(mockDbHealth)); - app.use( - ( - err: unknown, - _req: express.Request, - res: express.Response, - _next: express.NextFunction, - ) => { - const mapped = err as { statusCode: number; toResponse: () => unknown }; - res.status(mapped.statusCode).json(mapped.toResponse()); - }, - ); - - const response = await request(app).get("/startup"); - - expect(response.status).toBe(503); - expect(response.body.code).toBe(ErrorCode.SERVICE_UNAVAILABLE); - expect(response.body.details.dependency).toBe("database"); - }); -}); - -describe("createHealthRouter - k8s probe endpoints", () => { - it("mounts all health endpoints correctly", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 10, - pool: { - totalCount: 2, - idleCount: 2, - waitingCount: 0, - maxConnections: 10, - }, - }); - - const mockDb = { query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }) }; - const router = createHealthRouter(mockDb as any, mockDbHealth); - const app = express(); - app.use(router); - - const rootResponse = await request(app).get("/"); - expect(rootResponse.status).toBe(200); - - const liveResponse = await request(app).get("/live"); - expect(liveResponse.status).toBe(200); - expect(liveResponse.body.alive).toBe(true); - - const readyResponse = await request(app).get("/ready"); - expect([200, 503]).toContain(readyResponse.status); - - const startupResponse = await request(app).get("/startup"); - expect([200, 503]).toContain(startupResponse.status); - }); -}); - -describe("dependency graph security", () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it("never exposes raw database error messages in dependency health", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: false, - latencyMs: 100, - error: 'password authentication failed for user "admin"', - pool: { - totalCount: 0, - idleCount: 0, - waitingCount: 0, - maxConnections: 10, - }, - }); - global.fetch = jest - .fn() - .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(503); - expect(response.body.checks[0].error).toBe("sanitized-db-error"); - expect(response.body.checks[0].error).not.toContain("password"); - expect(response.body.checks[0].error).not.toContain("admin"); - expect(response.body.checks[0].error).not.toContain("authentication"); - }); - - it("exposes only safe Stellar metadata without leaking upstream details", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 20, - pool: { - totalCount: 2, - idleCount: 2, - waitingCount: 0, - maxConnections: 10, - }, - }); - global.fetch = jest - .fn() - .mockResolvedValue({ ok: false, status: 503 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(503); - expect(response.body.checks[1].name).toBe("stellar-horizon"); - expect(response.body.checks[1].status).toBe("down"); - expect(response.body.checks[1].details.failureClass).toBe( - StellarRPCFailureClass.UPSTREAM_ERROR, - ); - expect(response.body.checks[1].details.upstreamStatus).toBe(503); - expect(response.body.checks[1].details.url).toBeDefined(); - }); -}); - -describe("Stellar Horizon timeout handling", () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it("classifies Stellar timeout correctly", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 10, - pool: { - totalCount: 2, - idleCount: 2, - waitingCount: 0, - maxConnections: 10, - }, - }); - - const timeoutError = new Error("timeout"); - timeoutError.name = "AbortError"; - global.fetch = jest.fn().mockRejectedValue(timeoutError) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(503); - expect(response.body.checks[1].name).toBe("stellar-horizon"); - expect(response.body.checks[1].details.failureClass).toBe( - StellarRPCFailureClass.TIMEOUT, - ); - expect(response.body.checks[1].error).toBe("timeout"); - }); -}); - -describe("healthRootHandler - dependency graph aggregation", () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it("returns 503 unhealthy when Horizon is down and DB is up", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 12, - pool: { totalCount: 2, idleCount: 2, waitingCount: 0, maxConnections: 10 }, - }); - global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(503); - expect(response.body.status).toBe("unhealthy"); - expect(response.body.checks[0].name).toBe("database"); - expect(response.body.checks[0].status).toBe("up"); - expect(response.body.checks[1].name).toBe("stellar-horizon"); - expect(response.body.checks[1].status).toBe("down"); - expect(response.body.checks[1].healthy).toBe(false); - }); - - it("returns 200 degraded when both DB pool and Horizon are degraded", async () => { - // DB pool at 90% utilization → degraded; Horizon returns 200 but we simulate - // a degraded DB pool scenario. Horizon itself is up, so overall = degraded. - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 30, - pool: { totalCount: 9, idleCount: 1, waitingCount: 0, maxConnections: 10 }, - }); - global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(200); - expect(response.body.status).toBe("degraded"); - expect(response.body.checks[0].status).toBe("degraded"); - expect(response.body.checks[1].status).toBe("up"); - }); - - it("returns 503 unhealthy when DB checker throws an exception", async () => { - const mockDbHealth = jest.fn().mockRejectedValue(new Error("unexpected db crash")); - global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - // The handler should not crash the process; it should propagate as 500 or 503 - const response = await request(app).get("/health"); - - expect([500, 503]).toContain(response.status); - }); - - it("populates latencyMs on both database and stellar-horizon checks", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 42, - pool: { totalCount: 1, idleCount: 1, waitingCount: 0, maxConnections: 10 }, - }); - global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(200); - const dbCheck = response.body.checks.find((c: DependencyHealth) => c.name === "database"); - const stellarCheck = response.body.checks.find((c: DependencyHealth) => c.name === "stellar-horizon"); - - expect(typeof dbCheck.latencyMs).toBe("number"); - expect(dbCheck.latencyMs).toBeGreaterThanOrEqual(0); - expect(typeof stellarCheck.latencyMs).toBe("number"); - expect(stellarCheck.latencyMs).toBeGreaterThanOrEqual(0); - }); - - it("populates dependsOn on database check when pool metrics are present", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 8, - pool: { totalCount: 3, idleCount: 3, waitingCount: 0, maxConnections: 10 }, - }); - global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(200); - const dbCheck = response.body.checks.find((c: DependencyHealth) => c.name === "database"); - expect(Array.isArray(dbCheck.dependsOn)).toBe(true); - expect(dbCheck.dependsOn).toContain("db-pool"); - }); - - it("returns region info from healthRegionHandler", async () => { - const app = express(); - app.get("/region", healthRegionHandler("eu-west-1")); - - const response = await request(app).get("/region"); - - expect(response.status).toBe(200); - expect(response.body.region).toBe("eu-west-1"); - expect(response.body.activeRegion).toBe("eu-west-1"); - expect(response.body.isActive).toBe(true); - expect(response.body.service).toBe("revora-backend"); - expect(response.body.timestamp).toBeDefined(); - }); - - it("healthRegionHandler defaults to us-east-1 when no region provided", async () => { - const app = express(); - app.get("/region", healthRegionHandler()); - - const response = await request(app).get("/region"); - - expect(response.body.region).toBe("us-east-1"); - expect(response.body.isActive).toBe(true); - }); - - it("healthRegionHandler reports inactive when region mismatch", async () => { - process.env.FAILOVER_ACTIVE_REGION = "eu-west-1"; - const app = express(); - app.get("/region", healthRegionHandler("us-east-1")); - - const response = await request(app).get("/region"); - - expect(response.body.region).toBe("us-east-1"); - expect(response.body.activeRegion).toBe("eu-west-1"); - expect(response.body.isActive).toBe(false); - - delete process.env.FAILOVER_ACTIVE_REGION; - }); - - it("failover endpoint returns failover status from createApp", async () => { - process.env.REGION = "eu-west-1"; - process.env.FAILOVER_ACTIVE_REGION = "eu-west-1"; - const app = createApp({ - healthStatus: jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 5, - pool: { totalCount: 2, idleCount: 2, waitingCount: 0, maxConnections: 10 }, - }), - healthQuery: jest.fn(), - }); - - const response = await request(app).get("/health/failover"); - - expect(response.status).toBe(200); - expect(response.body.region).toBe("eu-west-1"); - expect(response.body.activeRegion).toBe("eu-west-1"); - expect(response.body.isActive).toBe(true); - expect(response.body.failoverActive).toBe(false); - expect(response.body.db).toBe("up"); - - delete process.env.REGION; - delete process.env.FAILOVER_ACTIVE_REGION; - }); - - it("failover endpoint reports failoverActive=true when region mismatch", async () => { - const originalRegion = process.env.REGION; - process.env.REGION = "us-east-1"; - process.env.FAILOVER_ACTIVE_REGION = "eu-west-1"; - const app = createApp({ - healthStatus: jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 5, - pool: { totalCount: 2, idleCount: 2, waitingCount: 0, maxConnections: 10 }, - }), - healthQuery: jest.fn(), - }); - - const response = await request(app).get("/health/failover"); - - expect(response.status).toBe(200); - expect(response.body.region).toBe("us-east-1"); - expect(response.body.activeRegion).toBe("eu-west-1"); - expect(response.body.isActive).toBe(false); - expect(response.body.failoverActive).toBe(true); - - if (originalRegion) process.env.REGION = originalRegion; - else delete process.env.REGION; - delete process.env.FAILOVER_ACTIVE_REGION; - }); - - it("failover endpoint returns 503 when db is down", async () => { - const app = createApp({ - healthStatus: jest.fn().mockResolvedValue({ - healthy: false, - latencyMs: 100, - error: "connection refused", - pool: { totalCount: 0, idleCount: 0, waitingCount: 0, maxConnections: 10 }, - }), - healthQuery: jest.fn(), - }); - - const response = await request(app).get("/health/failover"); - - expect(response.status).toBe(503); - expect(response.body.db).toBe("down"); - }); - - it("returns 503 unhealthy when both DB and Horizon are down", async () => { - const mockDbHealth = jest.fn().mockResolvedValue({ - healthy: false, - latencyMs: 200, - error: "connection refused", - pool: { totalCount: 0, idleCount: 0, waitingCount: 0, maxConnections: 10 }, - }); - global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503 }) as typeof fetch; - - const app = express(); - app.get("/health", healthRootHandler(mockDbHealth)); - - const response = await request(app).get("/health"); - - expect(response.status).toBe(503); - expect(response.body.status).toBe("unhealthy"); - expect(response.body.checks[0].status).toBe("down"); - expect(response.body.checks[1].status).toBe("down"); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Rate Limiter Tier Policies — integration tests (BE-011) -// -// Security assumptions under test: -// 1. Tier resolution defaults to "standard" when no tier header is sent. -// 2. Privileged tiers require the correct shared secret; wrong/absent secret -// silently downgrades to standard (fail-safe, never leaks tier info). -// 3. X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and -// X-RateLimit-Tier headers are always emitted. -// 4. Requests beyond the tier quota receive 429 with Retry-After. -// 5. Rate-limit counters are isolated per tier key prefix. -// 6. Non-register endpoints (/health) are unaffected by register rate limits. -// ───────────────────────────────────────────────────────────────────────────── -describe("Rate Limiter Tier Policies (BE-011)", () => { - const tierSecret = "integration-test-secret-be011"; - const API = "/api/v1"; - - /** - * @dev Each test builds its own createApp() instance so rate-limit counters - * start fresh — the in-process store is not shared across app instances. - */ - function makeApp() { - process.env.STARTUP_AUTH_TIER_SECRET = tierSecret; - const app = createApp({ - healthQuery: jest.fn().mockResolvedValue({ rows: [{ now: new Date() }] }), - healthStatus: jest.fn().mockResolvedValue({ - healthy: true, - latencyMs: 2, - pool: { totalCount: 1, idleCount: 1, waitingCount: 0, maxConnections: 10 }, - }), - }); - return app; - } - - afterEach(() => { - delete process.env.STARTUP_AUTH_TIER_SECRET; - }); - - // ── Header presence ───────────────────────────────────────────────────────── - - it("emits X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and X-RateLimit-Tier on every 201", async () => { - const app = makeApp(); - const res = await request(app) - .post(`${API}/startup/register`) - .send({ email: "user@example.com", password: "secret" }); - - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-limit"]).toBeDefined(); - expect(res.headers["x-ratelimit-remaining"]).toBeDefined(); - expect(res.headers["x-ratelimit-reset"]).toBeDefined(); - expect(res.headers["x-ratelimit-tier"]).toBeDefined(); - }); - - // ── Standard tier (default) ───────────────────────────────────────────────── - - it("resolves to standard tier when no tier header is provided", async () => { - const app = makeApp(); - const res = await request(app) - .post(`${API}/startup/register`) - .send({ email: "user@example.com", password: "secret" }); - - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-tier"]).toBe("standard"); - expect(res.headers["x-ratelimit-limit"]).toBe( - String(STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit), - ); - }); - - it("blocks standard-tier requests after quota is exhausted (6th request → 429)", async () => { - const app = makeApp(); - const body = { email: "u@example.com", password: "p" }; - - for (let i = 0; i < STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; i++) { - const r = await request(app).post(`${API}/startup/register`).send(body); - expect(r.status).toBe(201); - } - - const blocked = await request(app).post(`${API}/startup/register`).send(body); - expect(blocked.status).toBe(429); - expect(blocked.headers["x-ratelimit-tier"]).toBe("standard"); - expect(blocked.headers["retry-after"]).toBeDefined(); - expect(parseInt(blocked.headers["retry-after"], 10)).toBeGreaterThan(0); - }); - - // ── Trusted tier ───────────────────────────────────────────────────────────── - - it("resolves to trusted tier when valid secret is supplied", async () => { - const app = makeApp(); - const res = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") - .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) - .send({ email: "t@example.com", password: "p" }); - - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-tier"]).toBe("trusted"); - expect(res.headers["x-ratelimit-limit"]).toBe( - String(STARTUP_AUTH_RATE_TIER_POLICIES.trusted.limit), - ); - }); - - it("allows exactly trusted-limit requests and blocks the next one (11th → 429)", async () => { - const app = makeApp(); - const body = { email: "t@example.com", password: "p" }; - - for (let i = 0; i < STARTUP_AUTH_RATE_TIER_POLICIES.trusted.limit; i++) { - const r = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") - .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) - .send(body); - expect(r.status).toBe(201); - } - - const blocked = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") - .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) - .send(body); - expect(blocked.status).toBe(429); - expect(blocked.headers["x-ratelimit-tier"]).toBe("trusted"); - expect(blocked.headers["x-ratelimit-limit"]).toBe( - String(STARTUP_AUTH_RATE_TIER_POLICIES.trusted.limit), - ); - }); - - // ── Internal tier ──────────────────────────────────────────────────────────── - - it("resolves to internal tier when valid secret is supplied", async () => { - const app = makeApp(); - const res = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "internal") - .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) - .send({ email: "i@example.com", password: "p" }); - - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-tier"]).toBe("internal"); - expect(res.headers["x-ratelimit-limit"]).toBe( - String(STARTUP_AUTH_RATE_TIER_POLICIES.internal.limit), - ); - }); - - // ── Security: downgrade on bad secret ─────────────────────────────────────── - - it("downgrades 'trusted' request with wrong secret to standard tier (fail-safe)", async () => { - const app = makeApp(); - const res = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") - .set(STARTUP_AUTH_TIER_SECRET_HEADER, "wrong-secret") - .send({ email: "spoof@example.com", password: "p" }); - - // Must be treated as standard — does not reveal tier info - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-tier"]).toBe("standard"); - expect(res.headers["x-ratelimit-limit"]).toBe( - String(STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit), - ); - }); - - it("downgrades 'internal' request with absent secret to standard tier", async () => { - const app = makeApp(); - const res = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "internal") - // no secret header - .send({ email: "spoof@example.com", password: "p" }); - - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-tier"]).toBe("standard"); - }); - - it("spoofed trusted requests consume the standard counter; real trusted counter is untouched", async () => { - const app = makeApp(); - const body = { email: "s@example.com", password: "p" }; - - // Exhaust standard counter via spoofed trusted requests (wrong secret) - for (let i = 0; i < STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; i++) { - const r = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") - .set(STARTUP_AUTH_TIER_SECRET_HEADER, "bad-secret") - .send(body); - expect(r.status).toBe(201); - expect(r.headers["x-ratelimit-tier"]).toBe("standard"); - } - - // Standard counter is now exhausted — spoofed request is blocked - const spoofBlocked = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") - .set(STARTUP_AUTH_TIER_SECRET_HEADER, "bad-secret") - .send(body); - expect(spoofBlocked.status).toBe(429); - expect(spoofBlocked.headers["x-ratelimit-tier"]).toBe("standard"); - - // Trusted counter is completely fresh — real trusted request must succeed - const trustedOk = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") - .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) - .send(body); - expect(trustedOk.status).toBe(201); - expect(trustedOk.headers["x-ratelimit-tier"]).toBe("trusted"); - }); - - it("unknown tier value is treated as standard (no elevation)", async () => { - const app = makeApp(); - const res = await request(app) - .post(`${API}/startup/register`) - .set(STARTUP_AUTH_RATE_TIER_HEADER, "vip") - .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) - .send({ email: "vip@example.com", password: "p" }); - - expect(res.status).toBe(201); - expect(res.headers["x-ratelimit-tier"]).toBe("standard"); - }); - - // ── Isolation from other endpoints ────────────────────────────────────────── - - it("/health endpoint is completely unaffected when /startup/register is rate-limited", async () => { - const app = makeApp(); - const body = { email: "flood@example.com", password: "p" }; - - // Exhaust the standard tier - for (let i = 0; i <= STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; i++) { - await request(app).post(`${API}/startup/register`).send(body); - } - - // /health must still respond 200 - const healthRes = await request(app).get("/health"); - expect(healthRes.status).toBe(200); - }); - - // ── X-RateLimit-Remaining correctness ──────────────────────────────────────── - - it("X-RateLimit-Remaining decrements correctly on successive standard-tier requests", async () => { - const app = makeApp(); - const body = { email: "count@example.com", password: "p" }; - const limit = STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; - - const r1 = await request(app).post(`${API}/startup/register`).send(body); - expect(r1.status).toBe(201); - const r1Remaining = parseInt(r1.headers["x-ratelimit-remaining"], 10); - expect(r1Remaining).toBe(limit - 1); - - const r2 = await request(app).post(`${API}/startup/register`).send(body); - expect(r2.status).toBe(201); - const r2Remaining = parseInt(r2.headers["x-ratelimit-remaining"], 10); - expect(r2Remaining).toBe(limit - 2); - }); - - // ── 429 response body ──────────────────────────────────────────────────────── - - it("429 response body includes a human-readable message for the blocked tier", async () => { - const app = makeApp(); - const body = { email: "msg@example.com", password: "p" }; - - for (let i = 0; i < STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; i++) { - await request(app).post(`${API}/startup/register`).send(body); - } - - const blocked = await request(app).post(`${API}/startup/register`).send(body); - expect(blocked.status).toBe(429); - expect(typeof blocked.body.message).toBe("string"); - expect(blocked.body.message.length).toBeGreaterThan(0); - }); -}); - +import express from "express"; +import request from "supertest"; +import { + __test, + classifyStellarRPCFailure, + createApp, + StellarRPCFailureClass, + WebhookQueue, +} from '../index'; +import { closePool } from '../db/client'; +import { ErrorCode } from '../lib/errors'; +import { MetricsCollector } from '../lib/metrics'; +import { + calculateLag, + calculateUptimeSeconds, + createHealthRouter, + healthLiveHandler, + healthReadyHandler, + healthRegionHandler, + healthRootHandler, + healthStartupHandler, + mapHealthDependencyFailure, + HealthDependencyGraph, + DependencyHealth, +} from "./health"; +import { + STARTUP_AUTH_RATE_TIER_HEADER, + STARTUP_AUTH_TIER_SECRET_HEADER, + STARTUP_AUTH_RATE_TIER_POLICIES, +} from "../middleware/startupAuthRateTierPolicy"; + +afterAll(async () => { + await closePool(); +}); + +describe("classifyStellarRPCFailure", () => { + it("classifies timeout failures", () => { + const error = new Error("network timeout"); + error.name = "AbortError"; + + expect(classifyStellarRPCFailure(error).class).toBe( + StellarRPCFailureClass.TIMEOUT, + ); + }); + + it("classifies rate limit failures", () => { + expect(classifyStellarRPCFailure({ status: 429 }).class).toBe( + StellarRPCFailureClass.RATE_LIMIT, + ); + }); + + it("classifies auth failures", () => { + expect(classifyStellarRPCFailure({ status: 401 }).class).toBe( + StellarRPCFailureClass.UNAUTHORIZED, + ); + expect(classifyStellarRPCFailure({ status: 403 }).class).toBe( + StellarRPCFailureClass.UNAUTHORIZED, + ); + }); + + it("classifies upstream 5xx failures", () => { + expect(classifyStellarRPCFailure({ status: 503 }).class).toBe( + StellarRPCFailureClass.UPSTREAM_ERROR, + ); + }); + + it("classifies malformed responses", () => { + expect(classifyStellarRPCFailure(new SyntaxError("bad json")).class).toBe( + StellarRPCFailureClass.MALFORMED_RESPONSE, + ); + }); + + it("falls back to unknown for uncategorized errors", () => { + expect(classifyStellarRPCFailure(new Error("something odd")).class).toBe( + StellarRPCFailureClass.UNKNOWN, + ); + }); +}); + +describe("mapHealthDependencyFailure", () => { + it("sanitizes database dependency errors", () => { + const mapped = mapHealthDependencyFailure( + "database", + new Error("password auth failed"), + ); + + expect(mapped.toResponse()).toEqual({ + code: ErrorCode.SERVICE_UNAVAILABLE, + message: "Dependency unavailable", + details: { + dependency: "database", + }, + }); + }); + + it("preserves stable Stellar metadata without leaking raw upstream details", () => { + const mapped = mapHealthDependencyFailure("stellar-horizon", { + status: 503, + }); + + expect(mapped.toResponse()).toEqual({ + code: ErrorCode.SERVICE_UNAVAILABLE, + message: "Dependency unavailable", + details: { + dependency: "stellar-horizon", + failureClass: StellarRPCFailureClass.UPSTREAM_ERROR, + upstreamStatus: 503, + }, + }); + }); +}); + +describe("healthReadyHandler", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it("returns ok when both database and horizon are healthy", async () => { + const mockDb = { + query: jest.fn().mockResolvedValue({ rows: [{ "?column?": 1 }] }), + }; + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/ready", healthReadyHandler(mockDb as any)); + + const response = await request(app).get("/ready"); + + expect(response.status).toBe(200); + expect(response.body.ready).toBe(true); + expect(response.body.status).toBe("ok"); + expect(response.body.db).toBe("up"); + expect(response.body.stellar).toBe("up"); + expect(response.body.service).toBe("revora-backend"); + }); + + it("surfaces sanitized database failures", async () => { + const mockDb = { + query: jest.fn().mockRejectedValue(new Error("connection failed")), + }; + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/ready", healthReadyHandler(mockDb as any)); + app.use( + ( + err: any, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + const statusCode = err.statusCode || 500; + const body = typeof err.toResponse === 'function' ? err.toResponse() : { message: err.message }; + res.status(statusCode).json(body); + }, + ); + + const response = await request(app).get("/ready"); + + expect(response.status).toBe(503); + expect(response.body).toEqual({ + code: ErrorCode.SERVICE_UNAVAILABLE, + message: "Dependency unavailable", + details: { + dependency: "database", + }, + }); + }); + + it("maps Stellar upstream failures deterministically", async () => { + const mockDb = { + query: jest.fn().mockResolvedValue({ rows: [{ "?column?": 1 }] }), + }; + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 20, + pool: { + totalCount: 2, + idleCount: 2, + waitingCount: 0, + maxConnections: 10, + }, + }); + global.fetch = jest + .fn() + .mockResolvedValue({ ok: false, status: 429 }) as typeof fetch; + + const app = express(); + app.use("/health", createHealthRouter(mockDb as any, mockDbHealth)); + app.use( + ( + err: any, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + const statusCode = err.statusCode || 500; + const body = typeof err.toResponse === 'function' ? err.toResponse() : { message: err.message }; + res.status(statusCode).json(body); + }, + ); + + const response = await request(app).get("/health/ready"); + + expect(response.status).toBe(503); + expect(response.body).toEqual({ + code: ErrorCode.SERVICE_UNAVAILABLE, + message: "Dependency unavailable", + details: { + dependency: "stellar-horizon", + failureClass: StellarRPCFailureClass.RATE_LIMIT, + upstreamStatus: 429, + }, + }); + }); + + it('catches and surfaces fetch exceptions deterministically', async () => { + const db = { query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }) }; + const networkError = new Error('network timeout'); + networkError.name = 'AbortError'; + global.fetch = jest.fn().mockRejectedValue(networkError) as typeof fetch; + + const app = express(); + const mockDbHealth = jest.fn().mockResolvedValue({ healthy: true }); + app.use('/health', createHealthRouter(db as any, mockDbHealth)); + app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const mapped = err as { statusCode: number; toResponse: () => unknown }; + res.status(mapped.statusCode).json(mapped.toResponse()); + }); + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(503); + expect(response.body).toEqual({ + code: ErrorCode.SERVICE_UNAVAILABLE, + message: 'Dependency unavailable', + details: { + dependency: 'stellar-horizon', + failureClass: StellarRPCFailureClass.TIMEOUT, + }, + }); + }); +}); + +describe("offering validation matrix", () => { + const path = "/api/v1/offerings/validation-matrix"; + + function buildApp() { + return createApp({ + healthStatus: jest + .fn() + .mockResolvedValue({ healthy: true, latencyMs: 1 }), + healthQuery: jest.fn(), + }); + } + + function authHeaders(role: string, id = "actor-1") { + return { + "x-user-id": id, + "x-user-role": role, + }; + } + + it("rejects unauthenticated callers at the auth boundary", async () => { + const response = await request(buildApp()) + .post(path) + .send({ + action: "create", + offering: { targetAmount: "1000.00", minimumInvestment: "50.00" }, + }); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + code: ErrorCode.UNAUTHORIZED, + message: "Offering validation requires x-user-id and x-user-role headers", + }); + }); + + it("rejects invalid actions with an explicit schema error", async () => { + const response = await request(buildApp()) + .post(path) + .set(authHeaders("startup")) + .send({ + action: "destroy", + offering: {}, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + code: ErrorCode.BAD_REQUEST, + message: "Invalid offering validation action", + }); + }); + + it("allows a startup to publish its own valid draft offering", async () => { + const response = await request(buildApp()) + .post(path) + .set(authHeaders("startup", "issuer-1")) + .send({ + action: "publish", + offering: { + id: "off-1", + issuerId: "issuer-1", + status: "draft", + targetAmount: "1000.00", + minimumInvestment: "50.00", + subscriptionStartsAt: "2030-01-01T00:00:00.000Z", + subscriptionEndsAt: "2030-01-15T00:00:00.000Z", + }, + }); + + expect(response.status).toBe(200); + expect(response.body.allowed).toBe(true); + expect(response.body.decision).toBe("allow"); + expect(response.body.violations).toEqual([]); + }); + + it("denies a startup from managing another issuers offering", async () => { + const response = await request(buildApp()) + .post(path) + .set(authHeaders("startup", "issuer-1")) + .send({ + action: "pause", + offering: { + id: "off-2", + issuerId: "issuer-2", + status: "open", + }, + }); + + expect(response.status).toBe(422); + expect(response.body.allowed).toBe(false); + expect(response.body.violations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "OWNERSHIP_CONFIRMED", + }), + ]), + ); + }); + + it("denies investment attempts outside the allowed subscription window", async () => { + const response = await request(buildApp()) + .post(path) + .set(authHeaders("investor", "investor-1")) + .send({ + action: "invest", + offering: { + id: "off-3", + issuerId: "issuer-3", + status: "open", + targetAmount: "1000.00", + minimumInvestment: "100.00", + investmentAmount: "125.00", + subscriptionStartsAt: "2020-01-01T00:00:00.000Z", + subscriptionEndsAt: "2020-01-15T00:00:00.000Z", + }, + }); + + expect(response.status).toBe(422); + expect(response.body.allowed).toBe(false); + expect(response.body.violations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "INVESTMENT_WINDOW_ACTIVE", + }), + ]), + ); + }); + + it("blocks issuer self-investment by default", async () => { + const response = await request(buildApp()) + .post(path) + .set(authHeaders("investor", "issuer-4")) + .send({ + action: "invest", + offering: { + id: "off-4", + issuerId: "issuer-4", + status: "open", + targetAmount: "500.00", + minimumInvestment: "50.00", + investmentAmount: "50.00", + subscriptionStartsAt: "2030-01-01T00:00:00.000Z", + subscriptionEndsAt: "2030-01-10T00:00:00.000Z", + }, + }); + + expect(response.status).toBe(422); + expect(response.body.allowed).toBe(false); + expect(response.body.violations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "INVESTOR_NOT_ISSUER", + }), + ]), + ); + }); + + it("allows privileged compliance actors to review private offerings without ownership", async () => { + const response = await request(buildApp()) + .post(path) + .set(authHeaders("compliance", "compliance-1")) + .send({ + action: "viewPrivate", + offering: { + id: "off-5", + issuerId: "issuer-99", + status: "paused", + }, + }); + + expect(response.status).toBe(200); + expect(response.body.allowed).toBe(true); + expect(response.body.violations).toEqual([]); + }); + + it("returns degraded root health when the dependency checker reports failure", async () => { + const app = createApp({ + healthStatus: jest.fn().mockResolvedValue({ + healthy: false, + latencyMs: 4, + error: "sanitized-db-error", + }), + healthQuery: jest.fn(), + }); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(503); + expect(response.body).toEqual({ + status: "degraded", + service: "revora-backend", + db: { + healthy: false, + latencyMs: 4, + error: "sanitized-db-error", + }, + }); + }); + + it("serves the overview document on the versioned API prefix", async () => { + const response = await request(buildApp()).get("/api/v1/overview"); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + name: "Stellar RevenueShare (Revora) Backend", + version: "0.1.0", + }); + }); + + it("applies multi-tier rate limiting for startup registration", async () => { + const SECRET = "test-tier-secret"; + process.env.STARTUP_AUTH_TIER_SECRET = SECRET; + const path = "/api/v1/startup/register"; + + // 1. Standard Tier (Default: 5 requests) + { + const app = buildApp(); + for (let i = 0; i < 5; i++) { + const res = await request(app).post(path).send({ email: `std-${i}@test.com`, password: "Pass" }); + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-limit"]).toBe("5"); + } + const blockedStd = await request(app).post(path).send({ email: "std-fail@test.com", password: "Pass" }); + expect(blockedStd.status).toBe(429); + } + + // 2. Trusted Tier (10 requests) + { + const app = buildApp(); + const trustedHeaders = { "x-revora-rate-tier": "trusted", "x-revora-tier-secret": SECRET }; + for (let i = 0; i < 10; i++) { + const res = await request(app).post(path).set(trustedHeaders).send({ email: `trust-${i}@test.com`, password: "Pass" }); + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-limit"]).toBe("10"); + } + const blockedTrust = await request(app).post(path).set(trustedHeaders).send({ email: "trust-fail@test.com", password: "Pass" }); + expect(blockedTrust.status).toBe(429); + } + + // 3. Internal Tier (25 requests) + { + const app = buildApp(); + const internalHeaders = { "x-revora-rate-tier": "internal", "x-revora-tier-secret": SECRET }; + for (let i = 0; i < 25; i++) { + const res = await request(app).post(path).set(internalHeaders).send({ email: `int-${i}@test.com`, password: "Pass" }); + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-limit"]).toBe("25"); + } + const blockedInt = await request(app).post(path).set(internalHeaders).send({ email: "int-fail@test.com", password: "Pass" }); + expect(blockedInt.status).toBe(429); + } + + // 4. Invalid Secret Fallback (Standard Tier) + { + const app = buildApp(); + const invalidHeaders = { "x-revora-rate-tier": "internal", "x-revora-tier-secret": "wrong" }; + for (let i = 0; i < 5; i++) { + const res = await request(app).post(path).set(invalidHeaders).send({ email: `wrong-${i}@test.com`, password: "Pass" }); + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-limit"]).toBe("5"); + } + const blockedWrong = await request(app).post(path).set(invalidHeaders).send({ email: "wrong-fail@test.com", password: "Pass" }); + expect(blockedWrong.status).toBe(429); + } + }); + + it("rejects startup registration payloads that omit required credentials", async () => { + const response = await request(buildApp()) + .post("/api/v1/startup/register") + .send({ email: "missing-password@example.com" }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: "Email and password are required", + }); + }); +}); + +describe("WebhookQueue", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("classifies safe and unsafe webhook targets correctly", async () => { + const isSafeUrl = ( + WebhookQueue as unknown as { isSafeUrl: (url: string) => Promise } + ).isSafeUrl; + + expect(await isSafeUrl("https://example.com/hooks")).toBe(true); + expect(await isSafeUrl("http://127.0.0.1")).toBe(false); + expect(await isSafeUrl("http://localhost")).toBe(false); + expect(await isSafeUrl("not-a-valid-url")).toBe(false); + }); + + it("uses exponential backoff and stops after the configured retry ceiling", async () => { + const deliveryPromise = WebhookQueue.processDelivery( + "https://example.com/hooks", + { + event: "test", + }, + ); + + await jest.advanceTimersByTimeAsync(31_000); + + await expect(deliveryPromise).resolves.toBe(false); + expect(WebhookQueue.getBackoffDelay(0)).toBe(1000); + expect(WebhookQueue.getBackoffDelay(5)).toBe(-1); + }); + + it("fails fast for unsafe SSRF-style destinations", async () => { + await expect( + WebhookQueue.processDelivery("http://192.168.1.10/internal", { + event: "test", + }), + ).resolves.toBe(false); + }); +}); + +describe('health metrics collection', () => { + let metrics: MetricsCollector; + + beforeEach(() => { + metrics = new MetricsCollector({ enabled: true }); + }); + + afterEach(() => { + metrics.reset(); + }); + + it('should record successful health check metrics', async () => { + const db = { + query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), + }; + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get('/ready', healthReadyHandler(db, metrics)); + app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const mapped = err as { statusCode: number; toResponse: () => unknown }; + res.status(mapped.statusCode).json(mapped.toResponse()); + }); + + await request(app).get('/ready'); + + const snapshot = await metrics.getSnapshot(); + expect(snapshot.custom.length).toBeGreaterThan(0); + + // Check for health check metrics + const dbSuccess = snapshot.custom.find(m => + m.name === 'health_checks_total' && + m.labels?.check === 'database' && + m.labels?.status === 'success' + ); + expect(dbSuccess?.value).toBe(1); + + const stellarSuccess = snapshot.custom.find(m => + m.name === 'health_checks_total' && + m.labels?.check === 'stellar-horizon' && + m.labels?.status === 'success' + ); + expect(stellarSuccess?.value).toBe(1); + }); + + it('should record failed health check metrics', async () => { + const db = { + query: jest.fn().mockRejectedValue(new Error('connection failed')), + }; + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get('/ready', healthReadyHandler(db, metrics)); + app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const mapped = err as { statusCode: number; toResponse: () => unknown }; + res.status(mapped.statusCode).json(mapped.toResponse()); + }); + + await request(app).get('/ready'); + + const snapshot = await metrics.getSnapshot(); + const dbFailure = snapshot.custom.find(m => + m.name === 'health_checks_total' && + m.labels?.check === 'database' && + m.labels?.status === 'failure' + ); + expect(dbFailure?.value).toBe(1); + }); + + it('should record health check duration', async () => { + const db = { + query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), + }; + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get('/ready', healthReadyHandler(db, metrics)); + + await request(app).get('/ready'); + + const snapshot = await metrics.getSnapshot(); + const durationMetric = snapshot.custom.find(m => + m.name === 'health_check_duration_ms' && + m.labels?.endpoint === 'ready' + ); + expect(durationMetric).toBeDefined(); + expect(durationMetric?.value).toBeGreaterThanOrEqual(0); + }); + + it('should work without metrics collector', async () => { + const db = { + query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), + }; + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get('/ready', healthReadyHandler(db)); // No metrics + + const response = await request(app).get('/ready'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'ok', + db: 'up', + stellar: 'up', + ready: true, + service: 'revora-backend', + }); + }); +}); + +describe('__test helpers', () => { + it('stableSerialize sorts object keys recursively', () => { + expect( + __test.stableSerialize({ + b: 1, + a: { d: 4, c: 3 }, + }), + ).toBe('{"a":{"c":3,"d":4},"b":1}'); + }); + + it("stableSerialize preserves arrays while sorting nested object keys", () => { + expect( + __test.stableSerialize([ + { z: 2, a: 1 }, + { b: 2, a: 1 }, + ]), + ).toBe('[{"a":1,"z":2},{"a":1,"b":2}]'); + }); + + it("parseMoneyString accepts bounded decimal strings and rejects invalid input", () => { + expect(__test.parseMoneyString("999.99")).toBe(999.99); + expect(__test.parseMoneyString("1e6")).toBeNull(); + expect(__test.parseMoneyString(10)).toBeNull(); + }); + + it("parseIsoDate accepts valid ISO strings and rejects invalid dates", () => { + expect(__test.parseIsoDate("2030-01-01T00:00:00.000Z")?.toISOString()).toBe( + "2030-01-01T00:00:00.000Z", + ); + expect(__test.parseIsoDate("definitely-not-a-date")).toBeNull(); + }); + + it("parseOfferingValidationPayload preserves trimmed deterministic values", () => { + expect( + __test.parseOfferingValidationPayload({ + action: "create", + offering: { + issuerId: " issuer-1 ", + targetAmount: "100.00", + minimumInvestment: "10.00", + }, + }), + ).toEqual({ + action: "create", + offering: { + issuerId: "issuer-1", + targetAmount: "100.00", + minimumInvestment: "10.00", + }, + }); + }); + + it("parseOfferingValidationPayload rejects malformed bodies and invalid field values", () => { + expect(() => __test.parseOfferingValidationPayload(null)).toThrow( + "Validation payload must be a JSON object", + ); + expect(() => + __test.parseOfferingValidationPayload({ + action: "create", + }), + ).toThrow("Offering validation payload must include an offering object"); + expect(() => + __test.parseOfferingValidationPayload({ + action: "create", + offering: { id: " " }, + }), + ).toThrow("offering.id must be a non-empty string"); + expect(() => + __test.parseOfferingValidationPayload({ + action: "create", + offering: { issuerId: "" }, + }), + ).toThrow("offering.issuerId must be a non-empty string"); + expect(() => + __test.parseOfferingValidationPayload({ + action: "create", + offering: { status: "live" }, + }), + ).toThrow("offering.status must be a supported offering status"); + expect(() => + __test.parseOfferingValidationPayload({ + action: "create", + offering: { targetAmount: "" }, + }), + ).toThrow("offering.targetAmount must be a non-empty string"); + }); + + it("evaluateOfferingValidationMatrix covers close, cancel, and missing investment window rules", () => { + const actor = { id: "issuer-1", role: "startup" as const }; + + const closeResult = __test.evaluateOfferingValidationMatrix(actor, { + action: "close", + offering: { + issuerId: "issuer-1", + status: "paused", + }, + }); + expect(closeResult.allowed).toBe(true); + + const cancelResult = __test.evaluateOfferingValidationMatrix(actor, { + action: "cancel", + offering: { + issuerId: "issuer-1", + status: "closed", + }, + }); + expect(cancelResult.allowed).toBe(false); + expect(cancelResult.violations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "STATUS_ELIGIBLE_FOR_CANCEL" }), + ]), + ); + + const investResult = __test.evaluateOfferingValidationMatrix( + { id: "investor-1", role: "investor" }, + { + action: "invest", + offering: { + issuerId: "issuer-2", + status: "open", + targetAmount: "1000.00", + minimumInvestment: "50.00", + investmentAmount: "50.00", + }, + }, + new Date("2030-01-05T00:00:00.000Z"), + ); + + expect(investResult.allowed).toBe(false); + expect(investResult.violations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "INVESTMENT_WINDOW_ACTIVE" }), + ]), + ); + }); +}); + +describe("healthRootHandler - dependency graph", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it("returns comprehensive health with dependency graph when all services are healthy", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 25, + pool: { + totalCount: 5, + idleCount: 3, + waitingCount: 0, + maxConnections: 10, + }, + }); + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("healthy"); + expect(response.body.service).toBe("revora-backend"); + expect(response.body.checks).toHaveLength(2); + expect(response.body.checks[0].name).toBe("database"); + expect(response.body.checks[0].status).toBe("up"); + expect(response.body.checks[0].healthy).toBe(true); + expect(response.body.checks[0].details).toMatchObject({ + totalCount: 5, + idleCount: 3, + utilizationPercent: 50, + }); + expect(response.body.checks[1].name).toBe("stellar-horizon"); + expect(response.body.checks[1].status).toBe("up"); + expect(response.body.checks[1].healthy).toBe(true); + expect(response.body.uptime).toBeGreaterThanOrEqual(0); + expect(response.body.timestamp).toBeDefined(); + expect(response.body.version).toBeDefined(); + }); + + it("returns degraded status when pool utilization is high", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 50, + pool: { + totalCount: 9, + idleCount: 1, + waitingCount: 2, + maxConnections: 10, + }, + }); + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("degraded"); + expect(response.body.checks[0].status).toBe("degraded"); + expect(response.body.checks[0].details.utilizationPercent).toBe(90); + }); + + it("returns unhealthy status and 503 when database is down", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: false, + latencyMs: 100, + error: "connection refused", + pool: { + totalCount: 0, + idleCount: 0, + waitingCount: 0, + maxConnections: 10, + }, + }); + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(503); + expect(response.body.status).toBe("unhealthy"); + expect(response.body.checks[0].name).toBe("database"); + expect(response.body.checks[0].status).toBe("down"); + expect(response.body.checks[0].healthy).toBe(false); + expect(response.body.checks[0].error).toBe("sanitized-db-error"); + }); + + it("includes requestId in response when provided in headers", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 10, + pool: { + totalCount: 2, + idleCount: 2, + waitingCount: 0, + maxConnections: 10, + }, + }); + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app) + .get("/health") + .set("x-request-id", "test-req-123"); + + expect(response.body.requestId).toBe("test-req-123"); + }); +}); + +describe("healthLiveHandler - liveness probe", () => { + it("returns alive status for liveness probe", async () => { + const app = express(); + app.get("/live", healthLiveHandler()); + + const response = await request(app).get("/live"); + + expect(response.status).toBe(200); + expect(response.body.alive).toBe(true); + expect(response.body.service).toBe("revora-backend"); + expect(response.body.timestamp).toBeDefined(); + expect(response.body.uptime).toBeGreaterThanOrEqual(0); + }); + + it("includes requestId in liveness response when provided", async () => { + const app = express(); + app.get("/live", healthLiveHandler()); + + const response = await request(app) + .get("/live") + .set("x-request-id", "live-req-456"); + + expect(response.body.requestId).toBe("live-req-456"); + }); +}); + +describe("healthStartupHandler - startup probe", () => { + it("returns ready when database is healthy", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 15, + pool: { + totalCount: 3, + idleCount: 2, + waitingCount: 0, + maxConnections: 10, + }, + }); + + const app = express(); + app.get("/startup", healthStartupHandler(mockDbHealth)); + + const response = await request(app).get("/startup"); + + expect(response.status).toBe(200); + expect(response.body.ready).toBe(true); + expect(response.body.service).toBe("revora-backend"); + expect(response.body.check).toBe("database"); + }); + + it("returns 503 when database is not ready during startup", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: false, + latencyMs: 5000, + error: "timeout", + }); + + const app = express(); + app.get("/startup", healthStartupHandler(mockDbHealth)); + app.use( + ( + err: unknown, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + const mapped = err as { statusCode: number; toResponse: () => unknown }; + res.status(mapped.statusCode).json(mapped.toResponse()); + }, + ); + + const response = await request(app).get("/startup"); + + expect(response.status).toBe(503); + expect(response.body.code).toBe(ErrorCode.SERVICE_UNAVAILABLE); + expect(response.body.details.dependency).toBe("database"); + }); +}); + +describe("createHealthRouter - k8s probe endpoints", () => { + it("mounts all health endpoints correctly", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 10, + pool: { + totalCount: 2, + idleCount: 2, + waitingCount: 0, + maxConnections: 10, + }, + }); + + const mockDb = { query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }) }; + const router = createHealthRouter(mockDb as any, mockDbHealth); + const app = express(); + app.use(router); + + const rootResponse = await request(app).get("/"); + expect(rootResponse.status).toBe(200); + + const liveResponse = await request(app).get("/live"); + expect(liveResponse.status).toBe(200); + expect(liveResponse.body.alive).toBe(true); + + const readyResponse = await request(app).get("/ready"); + expect([200, 503]).toContain(readyResponse.status); + + const startupResponse = await request(app).get("/startup"); + expect([200, 503]).toContain(startupResponse.status); + }); +}); + +describe("dependency graph security", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it("never exposes raw database error messages in dependency health", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: false, + latencyMs: 100, + error: 'password authentication failed for user "admin"', + pool: { + totalCount: 0, + idleCount: 0, + waitingCount: 0, + maxConnections: 10, + }, + }); + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(503); + expect(response.body.checks[0].error).toBe("sanitized-db-error"); + expect(response.body.checks[0].error).not.toContain("password"); + expect(response.body.checks[0].error).not.toContain("admin"); + expect(response.body.checks[0].error).not.toContain("authentication"); + }); + + it("exposes only safe Stellar metadata without leaking upstream details", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 20, + pool: { + totalCount: 2, + idleCount: 2, + waitingCount: 0, + maxConnections: 10, + }, + }); + global.fetch = jest + .fn() + .mockResolvedValue({ ok: false, status: 503 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(503); + expect(response.body.checks[1].name).toBe("stellar-horizon"); + expect(response.body.checks[1].status).toBe("down"); + expect(response.body.checks[1].details.failureClass).toBe( + StellarRPCFailureClass.UPSTREAM_ERROR, + ); + expect(response.body.checks[1].details.upstreamStatus).toBe(503); + expect(response.body.checks[1].details.url).toBeDefined(); + }); +}); + +describe("Stellar Horizon timeout handling", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it("classifies Stellar timeout correctly", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 10, + pool: { + totalCount: 2, + idleCount: 2, + waitingCount: 0, + maxConnections: 10, + }, + }); + + const timeoutError = new Error("timeout"); + timeoutError.name = "AbortError"; + global.fetch = jest.fn().mockRejectedValue(timeoutError) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(503); + expect(response.body.checks[1].name).toBe("stellar-horizon"); + expect(response.body.checks[1].details.failureClass).toBe( + StellarRPCFailureClass.TIMEOUT, + ); + expect(response.body.checks[1].error).toBe("timeout"); + }); +}); + +describe("healthRootHandler - dependency graph aggregation", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it("returns 503 unhealthy when Horizon is down and DB is up", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 12, + pool: { totalCount: 2, idleCount: 2, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(503); + expect(response.body.status).toBe("unhealthy"); + expect(response.body.checks[0].name).toBe("database"); + expect(response.body.checks[0].status).toBe("up"); + expect(response.body.checks[1].name).toBe("stellar-horizon"); + expect(response.body.checks[1].status).toBe("down"); + expect(response.body.checks[1].healthy).toBe(false); + }); + + it("returns 200 degraded when both DB pool and Horizon are degraded", async () => { + // DB pool at 90% utilization → degraded; Horizon returns 200 but we simulate + // a degraded DB pool scenario. Horizon itself is up, so overall = degraded. + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 30, + pool: { totalCount: 9, idleCount: 1, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("degraded"); + expect(response.body.checks[0].status).toBe("degraded"); + expect(response.body.checks[1].status).toBe("up"); + }); + + it("returns 503 unhealthy when DB checker throws an exception", async () => { + const mockDbHealth = jest.fn().mockRejectedValue(new Error("unexpected db crash")); + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + // The handler should not crash the process; it should propagate as 500 or 503 + const response = await request(app).get("/health"); + + expect([500, 503]).toContain(response.status); + }); + + it("populates latencyMs on both database and stellar-horizon checks", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 42, + pool: { totalCount: 1, idleCount: 1, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + const dbCheck = response.body.checks.find((c: DependencyHealth) => c.name === "database"); + const stellarCheck = response.body.checks.find((c: DependencyHealth) => c.name === "stellar-horizon"); + + expect(typeof dbCheck.latencyMs).toBe("number"); + expect(dbCheck.latencyMs).toBeGreaterThanOrEqual(0); + expect(typeof stellarCheck.latencyMs).toBe("number"); + expect(stellarCheck.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it("populates dependsOn on database check when pool metrics are present", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 8, + pool: { totalCount: 3, idleCount: 3, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + const dbCheck = response.body.checks.find((c: DependencyHealth) => c.name === "database"); + expect(Array.isArray(dbCheck.dependsOn)).toBe(true); + expect(dbCheck.dependsOn).toContain("db-pool"); + }); + + it("returns region info from healthRegionHandler", async () => { + const app = express(); + app.get("/region", healthRegionHandler("eu-west-1")); + + const response = await request(app).get("/region"); + + expect(response.status).toBe(200); + expect(response.body.region).toBe("eu-west-1"); + expect(response.body.activeRegion).toBe("eu-west-1"); + expect(response.body.isActive).toBe(true); + expect(response.body.service).toBe("revora-backend"); + expect(response.body.timestamp).toBeDefined(); + }); + + it("healthRegionHandler defaults to us-east-1 when no region provided", async () => { + const app = express(); + app.get("/region", healthRegionHandler()); + + const response = await request(app).get("/region"); + + expect(response.body.region).toBe("us-east-1"); + expect(response.body.isActive).toBe(true); + }); + + it("healthRegionHandler reports inactive when region mismatch", async () => { + process.env.FAILOVER_ACTIVE_REGION = "eu-west-1"; + const app = express(); + app.get("/region", healthRegionHandler("us-east-1")); + + const response = await request(app).get("/region"); + + expect(response.body.region).toBe("us-east-1"); + expect(response.body.activeRegion).toBe("eu-west-1"); + expect(response.body.isActive).toBe(false); + + delete process.env.FAILOVER_ACTIVE_REGION; + }); + + it("failover endpoint returns failover status from createApp", async () => { + process.env.REGION = "eu-west-1"; + process.env.FAILOVER_ACTIVE_REGION = "eu-west-1"; + const app = createApp({ + healthStatus: jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 5, + pool: { totalCount: 2, idleCount: 2, waitingCount: 0, maxConnections: 10 }, + }), + healthQuery: jest.fn(), + }); + + const response = await request(app).get("/health/failover"); + + expect(response.status).toBe(200); + expect(response.body.region).toBe("eu-west-1"); + expect(response.body.activeRegion).toBe("eu-west-1"); + expect(response.body.isActive).toBe(true); + expect(response.body.failoverActive).toBe(false); + expect(response.body.db).toBe("up"); + + delete process.env.REGION; + delete process.env.FAILOVER_ACTIVE_REGION; + }); + + it("failover endpoint reports failoverActive=true when region mismatch", async () => { + const originalRegion = process.env.REGION; + process.env.REGION = "us-east-1"; + process.env.FAILOVER_ACTIVE_REGION = "eu-west-1"; + const app = createApp({ + healthStatus: jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 5, + pool: { totalCount: 2, idleCount: 2, waitingCount: 0, maxConnections: 10 }, + }), + healthQuery: jest.fn(), + }); + + const response = await request(app).get("/health/failover"); + + expect(response.status).toBe(200); + expect(response.body.region).toBe("us-east-1"); + expect(response.body.activeRegion).toBe("eu-west-1"); + expect(response.body.isActive).toBe(false); + expect(response.body.failoverActive).toBe(true); + + if (originalRegion) process.env.REGION = originalRegion; + else delete process.env.REGION; + delete process.env.FAILOVER_ACTIVE_REGION; + }); + + it("failover endpoint returns 503 when db is down", async () => { + const app = createApp({ + healthStatus: jest.fn().mockResolvedValue({ + healthy: false, + latencyMs: 100, + error: "connection refused", + pool: { totalCount: 0, idleCount: 0, waitingCount: 0, maxConnections: 10 }, + }), + healthQuery: jest.fn(), + }); + + const response = await request(app).get("/health/failover"); + + expect(response.status).toBe(503); + expect(response.body.db).toBe("down"); + }); + + it("returns 503 unhealthy when both DB and Horizon are down", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: false, + latencyMs: 200, + error: "connection refused", + pool: { totalCount: 0, idleCount: 0, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(503); + expect(response.body.status).toBe("unhealthy"); + expect(response.body.checks[0].status).toBe("down"); + expect(response.body.checks[1].status).toBe("down"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Rate Limiter Tier Policies — integration tests (BE-011) +// +// Security assumptions under test: +// 1. Tier resolution defaults to "standard" when no tier header is sent. +// 2. Privileged tiers require the correct shared secret; wrong/absent secret +// silently downgrades to standard (fail-safe, never leaks tier info). +// 3. X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and +// X-RateLimit-Tier headers are always emitted. +// 4. Requests beyond the tier quota receive 429 with Retry-After. +// 5. Rate-limit counters are isolated per tier key prefix. +// 6. Non-register endpoints (/health) are unaffected by register rate limits. +// ───────────────────────────────────────────────────────────────────────────── +describe("Rate Limiter Tier Policies (BE-011)", () => { + const tierSecret = "integration-test-secret-be011"; + const API = "/api/v1"; + + /** + * @dev Each test builds its own createApp() instance so rate-limit counters + * start fresh — the in-process store is not shared across app instances. + */ + function makeApp() { + process.env.STARTUP_AUTH_TIER_SECRET = tierSecret; + const app = createApp({ + healthQuery: jest.fn().mockResolvedValue({ rows: [{ now: new Date() }] }), + healthStatus: jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 2, + pool: { totalCount: 1, idleCount: 1, waitingCount: 0, maxConnections: 10 }, + }), + }); + return app; + } + + afterEach(() => { + delete process.env.STARTUP_AUTH_TIER_SECRET; + }); + + // ── Header presence ───────────────────────────────────────────────────────── + + it("emits X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and X-RateLimit-Tier on every 201", async () => { + const app = makeApp(); + const res = await request(app) + .post(`${API}/startup/register`) + .send({ email: "user@example.com", password: "secret" }); + + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-limit"]).toBeDefined(); + expect(res.headers["x-ratelimit-remaining"]).toBeDefined(); + expect(res.headers["x-ratelimit-reset"]).toBeDefined(); + expect(res.headers["x-ratelimit-tier"]).toBeDefined(); + }); + + // ── Standard tier (default) ───────────────────────────────────────────────── + + it("resolves to standard tier when no tier header is provided", async () => { + const app = makeApp(); + const res = await request(app) + .post(`${API}/startup/register`) + .send({ email: "user@example.com", password: "secret" }); + + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-tier"]).toBe("standard"); + expect(res.headers["x-ratelimit-limit"]).toBe( + String(STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit), + ); + }); + + it("blocks standard-tier requests after quota is exhausted (6th request → 429)", async () => { + const app = makeApp(); + const body = { email: "u@example.com", password: "p" }; + + for (let i = 0; i < STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; i++) { + const r = await request(app).post(`${API}/startup/register`).send(body); + expect(r.status).toBe(201); + } + + const blocked = await request(app).post(`${API}/startup/register`).send(body); + expect(blocked.status).toBe(429); + expect(blocked.headers["x-ratelimit-tier"]).toBe("standard"); + expect(blocked.headers["retry-after"]).toBeDefined(); + expect(parseInt(blocked.headers["retry-after"], 10)).toBeGreaterThan(0); + }); + + // ── Trusted tier ───────────────────────────────────────────────────────────── + + it("resolves to trusted tier when valid secret is supplied", async () => { + const app = makeApp(); + const res = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") + .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) + .send({ email: "t@example.com", password: "p" }); + + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-tier"]).toBe("trusted"); + expect(res.headers["x-ratelimit-limit"]).toBe( + String(STARTUP_AUTH_RATE_TIER_POLICIES.trusted.limit), + ); + }); + + it("allows exactly trusted-limit requests and blocks the next one (11th → 429)", async () => { + const app = makeApp(); + const body = { email: "t@example.com", password: "p" }; + + for (let i = 0; i < STARTUP_AUTH_RATE_TIER_POLICIES.trusted.limit; i++) { + const r = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") + .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) + .send(body); + expect(r.status).toBe(201); + } + + const blocked = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") + .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) + .send(body); + expect(blocked.status).toBe(429); + expect(blocked.headers["x-ratelimit-tier"]).toBe("trusted"); + expect(blocked.headers["x-ratelimit-limit"]).toBe( + String(STARTUP_AUTH_RATE_TIER_POLICIES.trusted.limit), + ); + }); + + // ── Internal tier ──────────────────────────────────────────────────────────── + + it("resolves to internal tier when valid secret is supplied", async () => { + const app = makeApp(); + const res = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "internal") + .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) + .send({ email: "i@example.com", password: "p" }); + + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-tier"]).toBe("internal"); + expect(res.headers["x-ratelimit-limit"]).toBe( + String(STARTUP_AUTH_RATE_TIER_POLICIES.internal.limit), + ); + }); + + // ── Security: downgrade on bad secret ─────────────────────────────────────── + + it("downgrades 'trusted' request with wrong secret to standard tier (fail-safe)", async () => { + const app = makeApp(); + const res = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") + .set(STARTUP_AUTH_TIER_SECRET_HEADER, "wrong-secret") + .send({ email: "spoof@example.com", password: "p" }); + + // Must be treated as standard — does not reveal tier info + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-tier"]).toBe("standard"); + expect(res.headers["x-ratelimit-limit"]).toBe( + String(STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit), + ); + }); + + it("downgrades 'internal' request with absent secret to standard tier", async () => { + const app = makeApp(); + const res = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "internal") + // no secret header + .send({ email: "spoof@example.com", password: "p" }); + + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-tier"]).toBe("standard"); + }); + + it("spoofed trusted requests consume the standard counter; real trusted counter is untouched", async () => { + const app = makeApp(); + const body = { email: "s@example.com", password: "p" }; + + // Exhaust standard counter via spoofed trusted requests (wrong secret) + for (let i = 0; i < STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; i++) { + const r = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") + .set(STARTUP_AUTH_TIER_SECRET_HEADER, "bad-secret") + .send(body); + expect(r.status).toBe(201); + expect(r.headers["x-ratelimit-tier"]).toBe("standard"); + } + + // Standard counter is now exhausted — spoofed request is blocked + const spoofBlocked = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") + .set(STARTUP_AUTH_TIER_SECRET_HEADER, "bad-secret") + .send(body); + expect(spoofBlocked.status).toBe(429); + expect(spoofBlocked.headers["x-ratelimit-tier"]).toBe("standard"); + + // Trusted counter is completely fresh — real trusted request must succeed + const trustedOk = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "trusted") + .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) + .send(body); + expect(trustedOk.status).toBe(201); + expect(trustedOk.headers["x-ratelimit-tier"]).toBe("trusted"); + }); + + it("unknown tier value is treated as standard (no elevation)", async () => { + const app = makeApp(); + const res = await request(app) + .post(`${API}/startup/register`) + .set(STARTUP_AUTH_RATE_TIER_HEADER, "vip") + .set(STARTUP_AUTH_TIER_SECRET_HEADER, tierSecret) + .send({ email: "vip@example.com", password: "p" }); + + expect(res.status).toBe(201); + expect(res.headers["x-ratelimit-tier"]).toBe("standard"); + }); + + // ── Isolation from other endpoints ────────────────────────────────────────── + + it("/health endpoint is completely unaffected when /startup/register is rate-limited", async () => { + const app = makeApp(); + const body = { email: "flood@example.com", password: "p" }; + + // Exhaust the standard tier + for (let i = 0; i <= STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; i++) { + await request(app).post(`${API}/startup/register`).send(body); + } + + // /health must still respond 200 + const healthRes = await request(app).get("/health"); + expect(healthRes.status).toBe(200); + }); + + // ── X-RateLimit-Remaining correctness ──────────────────────────────────────── + + it("X-RateLimit-Remaining decrements correctly on successive standard-tier requests", async () => { + const app = makeApp(); + const body = { email: "count@example.com", password: "p" }; + const limit = STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; + + const r1 = await request(app).post(`${API}/startup/register`).send(body); + expect(r1.status).toBe(201); + const r1Remaining = parseInt(r1.headers["x-ratelimit-remaining"], 10); + expect(r1Remaining).toBe(limit - 1); + + const r2 = await request(app).post(`${API}/startup/register`).send(body); + expect(r2.status).toBe(201); + const r2Remaining = parseInt(r2.headers["x-ratelimit-remaining"], 10); + expect(r2Remaining).toBe(limit - 2); + }); + + // ── 429 response body ──────────────────────────────────────────────────────── + + it("429 response body includes a human-readable message for the blocked tier", async () => { + const app = makeApp(); + const body = { email: "msg@example.com", password: "p" }; + + for (let i = 0; i < STARTUP_AUTH_RATE_TIER_POLICIES.standard.limit; i++) { + await request(app).post(`${API}/startup/register`).send(body); + } + + const blocked = await request(app).post(`${API}/startup/register`).send(body); + expect(blocked.status).toBe(429); + expect(typeof blocked.body.message).toBe("string"); + expect(blocked.body.message.length).toBeGreaterThan(0); + }); +}); + +// Rate limiter tests + \ No newline at end of file