From cb42bd6d8b9ea8787f5d47294aa40cc73ddc1cc0 Mon Sep 17 00:00:00 2001 From: ayinde38 Date: Tue, 25 Aug 2026 12:10:21 +0100 Subject: [PATCH] feat(api): contract pause guard, batch invoice publishing, KYC audit log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #136, #137, #179, #181. #136 — emergency contract pause detector and lockdown handler Adds ContractGuardService, which reads a contract's persistent `Paused` storage entry over Soroban RPC and caches it for 15 seconds so a burst of API traffic collapses into one RPC call per contract instead of one per request. A contract that has never been paused has no entry at all, which reads as not paused. On RPC failure the service serves the last reading it has, even an expired one, and only assumes "not paused" when it has never had one. Failing closed on every blip would take the API down for a network wobble; failing open would let trades through during a real incident. Both fallbacks log at warn. checkContractNotPaused short-circuits with 503 CONTRACT_PAUSED and is applied to the state-changing investment and settlement endpoints only — browsing during a pause is harmless, and blocking reads would hide the state of the system from the people who need to see it. The guard is inert unless both SOROBAN_RPC_URL and SOROBAN_ESCROW_CONTRACT_ID are configured, so existing deployments behave exactly as before. #137 — batch invoice publishing POST /api/v1/invoices/batch-publish takes { invoiceIds } (1-100 uuids) and publishes them inside one TypeORM transaction. Every invoice is validated first — ownership, DRAFT status, and the pre-publish field rules — and if any one fails the whole batch is rejected with nothing written, so a seller can never end up with a half-published book. The 400 response names every rejected invoice and why, so all problems can be fixed in one pass. An invoice belonging to another seller is reported with the same wording as a missing one, so the response does not confirm that someone else's id exists. #179 — funding deadline validator The deadline check reported both an expired invoice and a merely tight one as DUE_DATE_TOO_SOON. Splits out DUE_DATE_IN_PAST, since the two mean different things to the seller: one is stale, the other is fixable by moving the date. Extracts validateFundingDeadline with an injectable `now` so the boundary is testable, and adds coverage at the exact 24h cutoff, either side of it by a millisecond, 7 days out, and at/just before now. Two tests pin that the validator reads the server clock and ignores any timestamp on the request payload. The existing suite's past-date expectations move to the new code; the rejection itself is unchanged. #181 — KYC status change audit log Approve, reject and revoke now emit one shared "KYC status change" entry carrying wallet, previous_status, new_status, reviewer_wallet and changed_at, always after the write has committed so an entry is never written for a decision that failed to persist. previous_status is captured before the update. Wallets are truncated the same way as elsewhere. This replaces the separate approval/rejection decision logs, which carried neither the previous status nor the reviewer's wallet. There was no revoke endpoint to log, so POST /api/v1/admin/revoke-kyc is added. It returns an approved user to PENDING rather than REJECTED — they are no longer cleared to trade, but the decision is "needs review again", not "rejected on the merits". Revoking a user who is not approved is a 409 rather than a no-op that still writes an audit entry. Both new routes are documented in docs/openapi.json. --- docs/openapi.json | 203 ++++++++++++- src/app.ts | 30 +- src/config/env.ts | 3 + src/controllers/invoice.controller.ts | 38 +++ src/lib/invoice-lifecycle-log.ts | 6 +- src/lib/kyc-status-log.ts | 53 ++++ src/lib/validate-invoice-for-publish.ts | 75 ++++- .../contract-pause-guard.middleware.ts | 68 +++++ src/routes/admin/admin.routes.ts | 5 + src/routes/admin/approve-kyc.ts | 20 +- src/routes/admin/reject-kyc.ts | 22 +- src/routes/admin/revoke-kyc.ts | 74 +++++ src/routes/investment.routes.ts | 16 +- src/routes/invoice.routes.ts | 25 ++ src/routes/settlement.routes.ts | 14 +- src/services/invoice.service.ts | 153 ++++++++++ .../stellar/contract-guard.service.ts | 215 ++++++++++++++ tests/funding-deadline-validator.test.ts | 190 ++++++++++++ tests/unit/contract-guard.service.test.ts | 236 +++++++++++++++ .../contract-pause-guard.middleware.test.ts | 130 +++++++++ tests/unit/invoice-batch-publish.test.ts | 270 ++++++++++++++++++ tests/unit/kyc-admin-routes.test.ts | 226 +++++++++++++-- tests/validate-invoice-for-publish.test.ts | 6 +- 23 files changed, 2019 insertions(+), 59 deletions(-) create mode 100644 src/lib/kyc-status-log.ts create mode 100644 src/middleware/contract-pause-guard.middleware.ts create mode 100644 src/routes/admin/revoke-kyc.ts create mode 100644 src/services/stellar/contract-guard.service.ts create mode 100644 tests/funding-deadline-validator.test.ts create mode 100644 tests/unit/contract-guard.service.test.ts create mode 100644 tests/unit/contract-pause-guard.middleware.test.ts create mode 100644 tests/unit/invoice-batch-publish.test.ts diff --git a/docs/openapi.json b/docs/openapi.json index 210e2e8..90081c5 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -38,7 +38,10 @@ "example": "AAAA..." } }, - "required": ["status", "contractId"] + "required": [ + "status", + "contractId" + ] }, "ErrorResponse": { "type": "object", @@ -50,6 +53,70 @@ "type": "string" } } + }, + "BatchPublishRequest": { + "type": "object", + "required": [ + "invoiceIds" + ], + "properties": { + "invoiceIds": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Ids of the draft invoices to publish." + } + } + }, + "BatchPublishResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "published": { + "type": "array", + "items": { + "type": "object" + }, + "description": "The invoices that were published." + }, + "count": { + "type": "integer", + "example": 3 + } + } + } + } + }, + "RevokeKYCRequest": { + "type": "object", + "required": [ + "userId", + "reviewerId" + ], + "properties": { + "userId": { + "type": "string", + "description": "User whose approval is being withdrawn." + }, + "reviewerId": { + "type": "string", + "description": "Admin performing the revocation." + }, + "revocationReason": { + "type": "string", + "description": "Recorded in the KYC audit log." + } + } } } }, @@ -117,6 +184,140 @@ } } } + }, + "/api/v1/invoices/batch-publish": { + "post": { + "summary": "Batch publish draft invoices", + "description": "Publishes several of the authenticated seller's draft invoices to the marketplace in a single atomic transaction. Every invoice is validated first; if any one of them cannot be published the whole batch is rejected and no invoice changes state. The 400 response lists every rejected invoice and why.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchPublishRequest" + } + } + } + }, + "responses": { + "200": { + "description": "All invoices published", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchPublishResponse" + } + } + } + }, + "400": { + "description": "Batch rejected; no invoice was changed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "KYC approval required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/admin/revoke-kyc": { + "post": { + "summary": "Revoke a user's KYC approval", + "description": "Withdraws a previously granted KYC approval, returning the user to the pending state so they can be reviewed again. Only an approved user can be revoked. Requires the admin API key and an allow-listed source IP.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeKYCRequest" + } + } + } + }, + "responses": { + "200": { + "description": "KYC revoked" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "User is not currently approved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } } } } diff --git a/src/app.ts b/src/app.ts index 09d4413..76e798f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,7 @@ import { createInvestmentRouter } from "./routes/investment.routes"; import { createSettlementRouter } from "./routes/settlement.routes"; import { createMarketplaceRouter } from "./routes/marketplace.routes"; import { createAdminRouter } from "./routes/admin/admin.routes"; +import { createContractGuardService } from "./services/stellar/contract-guard.service"; import type { AuthService } from "./services/auth.service"; import type { NotificationService } from "./services/notification.service"; @@ -181,12 +182,37 @@ export function createApp({ app.use("/api/v1/invoices", createInvoiceRouter({ invoiceService, config })); } + // The emergency pause guard only has something to check when a Soroban + // contract and an RPC endpoint are both configured; otherwise the routers + // mount without it and behave exactly as before. + const pauseGuardContractId = config?.sorobanEscrow.contractId ?? null; + const pauseGuardRpcUrl = config?.sorobanEscrow.rpcUrl ?? null; + const contractGuardService = + pauseGuardRpcUrl && pauseGuardContractId + ? createContractGuardService({ rpcUrl: pauseGuardRpcUrl }) + : undefined; + if (investmentService) { - app.use("/api/v1/investments", createInvestmentRouter({ investmentService, authService })); + app.use( + "/api/v1/investments", + createInvestmentRouter({ + investmentService, + authService, + contractGuardService, + contractId: pauseGuardContractId, + }), + ); } if (settlementService) { - app.use("/api/v1/settlements", createSettlementRouter({ settlementService })); + app.use( + "/api/v1/settlements", + createSettlementRouter({ + settlementService, + contractGuardService, + contractId: pauseGuardContractId, + }), + ); } if (marketplaceService) { diff --git a/src/config/env.ts b/src/config/env.ts index 67156f4..5f85fa8 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -46,6 +46,8 @@ export interface AppConfig { enabled: boolean; contractId: string | null; fundingMode: "wallet_xdr"; + /** Soroban JSON-RPC endpoint used to read on-chain contract state. */ + rpcUrl: string | null; }; ipfs: { apiUrl: string; @@ -255,6 +257,7 @@ export function getConfig(): AppConfig { enabled: parseBoolean(process.env.SOROBAN_ESCROW_ENABLED, false, "SOROBAN_ESCROW_ENABLED"), contractId: process.env.SOROBAN_ESCROW_CONTRACT_ID ?? null, fundingMode: "wallet_xdr", + rpcUrl: process.env.SOROBAN_RPC_URL ?? null, }, ipfs: { diff --git a/src/controllers/invoice.controller.ts b/src/controllers/invoice.controller.ts index 0b570a9..ca76088 100644 --- a/src/controllers/invoice.controller.ts +++ b/src/controllers/invoice.controller.ts @@ -52,6 +52,12 @@ export interface PublishInvoiceRequest extends AuthenticatedRequest { }; } +export interface BatchPublishInvoicesRequest extends AuthenticatedRequest { + body: { + invoiceIds: string[]; + }; +} + export function createInvoiceController(invoiceService: InvoiceService) { return { async createInvoice( @@ -299,6 +305,38 @@ export function createInvoiceController(invoiceService: InvoiceService) { } }, + async batchPublishInvoices( + req: BatchPublishInvoicesRequest, + res: Response, + next: NextFunction, + ): Promise { + try { + if (!req.user) { + throw new HttpError(401, "Authentication required"); + } + + const result = await invoiceService.publishInvoicesBatch({ + invoiceIds: req.body.invoiceIds, + sellerId: req.user.id, + }); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + if (error instanceof ServiceError) { + // Per-invoice rejections are the point of the endpoint: the seller + // needs to see every problem at once, so they are passed through as + // error details rather than collapsed into a message. + next(new HttpError(error.statusCode, error.message, error.details)); + return; + } + + next(error); + } + }, + async uploadDocument( req: UploadDocumentRequest, res: Response, diff --git a/src/lib/invoice-lifecycle-log.ts b/src/lib/invoice-lifecycle-log.ts index 102dbd6..d1d7e97 100644 --- a/src/lib/invoice-lifecycle-log.ts +++ b/src/lib/invoice-lifecycle-log.ts @@ -2,7 +2,11 @@ import type { AppLogger } from "../observability/logger"; import { truncateWalletAddress } from "./kyc"; import type { InvoiceStatus } from "../types/enums"; -export type InvoiceTransitionReason = "seller_published" | "fully_funded" | "admin_settled"; +export type InvoiceTransitionReason = + | "seller_published" + | "seller_batch_published" + | "fully_funded" + | "admin_settled"; export interface InvoiceTransitionLogInput { invoiceId: string; diff --git a/src/lib/kyc-status-log.ts b/src/lib/kyc-status-log.ts new file mode 100644 index 0000000..475e176 --- /dev/null +++ b/src/lib/kyc-status-log.ts @@ -0,0 +1,53 @@ +import type { AppLogger } from "../observability/logger"; +import type { KYCStatus } from "../types/enums"; +import { truncateWalletAddress } from "./kyc"; + +/** The administrative action that produced a KYC status change. */ +export type KYCStatusChangeAction = "approve" | "reject" | "revoke"; + +export interface KYCStatusChangeInput { + /** Wallet address of the user whose KYC status changed. */ + wallet: string; + /** The status the user held immediately before this change. */ + previousStatus: KYCStatus; + /** The status now persisted for the user. */ + newStatus: KYCStatus; + /** Wallet address of the admin who made the decision. */ + reviewerWallet: string; + /** Internal id of the reviewing admin, recorded alongside their wallet. */ + reviewerId?: string; + /** Which admin action this was. */ + action: KYCStatusChangeAction; + /** Free-form reason, recorded for rejections and revocations. */ + reason?: string; + /** Override for the change timestamp; defaults to the server clock. */ + changedAt?: Date; +} + +/** + * The single audit-trail entry for a KYC status change. + * + * Every approve, reject and revoke goes through here so the five audit fields + * — who, which wallet, from what, to what, and when — are always present and + * always spelled the same way, which is what makes the log queryable. + * + * Call this *after* the status has been persisted and before the response is + * sent: an entry here is a claim that the change is durable, so it must never + * be written for a decision that failed to commit. + * + * Wallet addresses are truncated the same way as everywhere else in the + * codebase — enough to identify an account in an investigation, not enough to + * spill full addresses into log aggregation. + */ +export function logKYCStatusChange(logger: AppLogger, input: KYCStatusChangeInput): void { + logger.info("KYC status change", { + wallet: truncateWalletAddress(input.wallet), + previous_status: input.previousStatus, + new_status: input.newStatus, + reviewer_wallet: truncateWalletAddress(input.reviewerWallet), + changed_at: (input.changedAt ?? new Date()).toISOString(), + action: input.action, + ...(input.reviewerId ? { reviewer_id: input.reviewerId } : {}), + ...(input.reason ? { reason: input.reason } : {}), + }); +} diff --git a/src/lib/validate-invoice-for-publish.ts b/src/lib/validate-invoice-for-publish.ts index 5c2d59a..ca26390 100644 --- a/src/lib/validate-invoice-for-publish.ts +++ b/src/lib/validate-invoice-for-publish.ts @@ -7,14 +7,75 @@ export interface ValidationError { message: string; } -const MIN_LEAD_TIME_MS = 24 * 60 * 60 * 1000; +/** + * Minimum runway between publishing and the funding deadline. + * + * Investors need a full day to evaluate and fund an invoice, so a deadline + * closer than this is rejected even though it is still in the future. + */ +export const MIN_LEAD_TIME_MS = 24 * 60 * 60 * 1000; export const MIN_FACE_VALUE_XLM = new Decimal("100"); +/** + * Validates an invoice's funding deadline against the server clock. + * + * Three outcomes, and the two failures are reported separately because they + * mean different things to the seller: a deadline that has already passed is a + * stale invoice, while one inside the 24-hour window is simply too tight and + * can be fixed by pushing the date out. + * + * - `DUE_DATE_IN_PAST` — the deadline is at or before `now`, or unparseable + * - `DUE_DATE_TOO_SOON` — the deadline is in the future but under + * {@link MIN_LEAD_TIME_MS} away + * - `null` — the deadline is at least {@link MIN_LEAD_TIME_MS} away + * + * `now` defaults to the server clock. It is a parameter so tests can pin it; + * callers must never pass a client-supplied timestamp, or a seller could + * publish an expired invoice by lying about the time. + */ +export function validateFundingDeadline( + dueDate: Date | string, + now: Date = new Date(), +): ValidationError | null { + const deadline = new Date(dueDate); + + if (Number.isNaN(deadline.getTime())) { + return { + field: "dueDate", + code: "DUE_DATE_IN_PAST", + message: "Invoice funding deadline is missing or not a valid date.", + }; + } + + const leadTimeMs = deadline.getTime() - now.getTime(); + + if (leadTimeMs <= 0) { + return { + field: "dueDate", + code: "DUE_DATE_IN_PAST", + message: "Invoice funding deadline is in the past.", + }; + } + + if (leadTimeMs < MIN_LEAD_TIME_MS) { + return { + field: "dueDate", + code: "DUE_DATE_TOO_SOON", + message: "Invoice funding deadline must be at least 24 hours in the future.", + }; + } + + return null; +} + /** * Validates that an invoice meets the minimum field requirements * required for the draft -> published lifecycle transition. + * + * `now` is threaded through to {@link validateFundingDeadline} for tests only; + * production callers use the default server clock. */ -export function validateInvoiceForPublish(invoice: Invoice): ValidationError[] { +export function validateInvoiceForPublish(invoice: Invoice, now: Date = new Date()): ValidationError[] { const errors: ValidationError[] = []; const faceValue = new Decimal(invoice.amount); @@ -26,13 +87,9 @@ export function validateInvoiceForPublish(invoice: Invoice): ValidationError[] { }); } - const dueDate = new Date(invoice.dueDate); - if (Number.isNaN(dueDate.getTime()) || dueDate.getTime() - Date.now() < MIN_LEAD_TIME_MS) { - errors.push({ - field: "dueDate", - code: "DUE_DATE_TOO_SOON", - message: "Invoice due date must be at least 24 hours in the future.", - }); + const deadlineError = validateFundingDeadline(invoice.dueDate, now); + if (deadlineError) { + errors.push(deadlineError); } if (!invoice.ipfsHash) { diff --git a/src/middleware/contract-pause-guard.middleware.ts b/src/middleware/contract-pause-guard.middleware.ts new file mode 100644 index 0000000..4153216 --- /dev/null +++ b/src/middleware/contract-pause-guard.middleware.ts @@ -0,0 +1,68 @@ +import type { NextFunction, Request, Response } from "express"; +import type { ContractGuardService } from "../services/stellar/contract-guard.service"; +import type { AppLogger } from "../observability/logger"; +import { logger as globalLogger } from "../observability/logger"; + +export interface ContractPauseGuardOptions { + contractGuardService: ContractGuardService; + /** Contract to check. When null the guard is inert and every request passes. */ + contractId: string | null; + logger?: AppLogger; +} + +/** + * Blocks requests while the underlying Soroban contract is paused. + * + * When contracts are paused on-chain — during a security investigation, say — + * any funding, investment or settlement call the API accepts would fail at + * submission time anyway, after the user has already committed to it. Rejecting + * up front with a 503 is both faster and clearer than letting the request reach + * the chain and bounce. + * + * 503 rather than 403: this is a temporary, whole-system condition the caller + * can retry out of, not a permission problem with their request. + * + * Read-only endpoints should not use this guard. Browsing the marketplace + * during a pause is harmless, and blocking it would hide the state of the + * system from the people who need to see it. + */ +export function checkContractNotPaused({ + contractGuardService, + contractId, + logger = globalLogger, +}: ContractPauseGuardOptions) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!contractId) { + next(); + return; + } + + try { + const paused = await contractGuardService.checkContractPauseState(contractId); + + if (paused) { + logger.warn("Request blocked: smart contract is paused", { + contract_id: contractId, + method: req.method, + path: req.originalUrl ?? req.path, + }); + + res.status(503).json({ + success: false, + error: { + code: "CONTRACT_PAUSED", + message: "Smart contract operations are currently paused by administration.", + }, + }); + return; + } + + next(); + } catch (error) { + // The service already degrades gracefully on RPC failure, so reaching + // here means something unexpected broke. Fail the request rather than + // waving it through on an unknown pause state. + next(error); + } + }; +} diff --git a/src/routes/admin/admin.routes.ts b/src/routes/admin/admin.routes.ts index e7d067f..9bb7f4b 100644 --- a/src/routes/admin/admin.routes.ts +++ b/src/routes/admin/admin.routes.ts @@ -4,6 +4,7 @@ import { DataSource } from "typeorm"; import { ipWhitelistMiddleware } from "@/middleware/ip-whitelist.middleware"; import { approveKYC } from "./approve-kyc"; import { rejectKYC } from "./reject-kyc"; +import { revokeKYC } from "./revoke-kyc"; export interface AdminRouterDependencies { dataSource: DataSource; @@ -27,5 +28,9 @@ export function createAdminRouter({ rejectKYC(req, res, dataSource); }); + router.post("/revoke-kyc", (req, res) => { + revokeKYC(req, res, dataSource); + }); + return router; } \ No newline at end of file diff --git a/src/routes/admin/approve-kyc.ts b/src/routes/admin/approve-kyc.ts index c179d63..2b9f3af 100644 --- a/src/routes/admin/approve-kyc.ts +++ b/src/routes/admin/approve-kyc.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { DataSource } from "typeorm"; import { User } from "@/models/User.model"; import { KYCStatus } from "@/types/enums"; -import { truncateWalletAddress } from "@/lib/kyc"; +import { logKYCStatusChange } from "@/lib/kyc-status-log"; import { logger } from "@/observability/logger"; interface ApproveKYCBody { @@ -25,16 +25,22 @@ export async function approveKYC(req: Request, return res.status(404).json({ error: "User not found" }); } + // Captured before the update so the audit entry records what the status + // actually moved from, not what it moved to. + const previousStatus = user.kycStatus; + const reviewer = await userRepo.findOneBy({ id: reviewerId }); + await userRepo.update(userId, { kycStatus: KYCStatus.APPROVED }); // Logged only after the DB update succeeds, so the audit trail never // records a decision that didn't actually persist. - const decidedAt = new Date().toISOString(); - logger.info("KYC approval decision", { - wallet_address: truncateWalletAddress(user.stellarAddress), - decision: "approved", - reviewer_id: reviewerId, - decided_at: decidedAt, + logKYCStatusChange(logger, { + wallet: user.stellarAddress, + previousStatus, + newStatus: KYCStatus.APPROVED, + reviewerWallet: reviewer?.stellarAddress ?? reviewerId, + reviewerId, + action: "approve", }); return res.json({ success: true }); diff --git a/src/routes/admin/reject-kyc.ts b/src/routes/admin/reject-kyc.ts index 60904a6..75ac4a1 100644 --- a/src/routes/admin/reject-kyc.ts +++ b/src/routes/admin/reject-kyc.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { DataSource } from "typeorm"; import { User } from "@/models/User.model"; import { KYCStatus } from "@/types/enums"; -import { truncateWalletAddress } from "@/lib/kyc"; +import { logKYCStatusChange } from "@/lib/kyc-status-log"; import { logger } from "@/observability/logger"; interface RejectKYCBody { @@ -26,17 +26,23 @@ export async function rejectKYC(req: Request, r return res.status(404).json({ error: "User not found" }); } + // Captured before the update so the audit entry records what the status + // actually moved from, not what it moved to. + const previousStatus = user.kycStatus; + const reviewer = await userRepo.findOneBy({ id: reviewerId }); + await userRepo.update(userId, { kycStatus: KYCStatus.REJECTED }); // Logged only after the DB update succeeds, so the audit trail never // records a decision that didn't actually persist. - const decidedAt = new Date().toISOString(); - logger.info("KYC rejection decision", { - wallet_address: truncateWalletAddress(user.stellarAddress), - decision: "rejected", - reviewer_id: reviewerId, - decided_at: decidedAt, - rejection_reason: rejectionReason, + logKYCStatusChange(logger, { + wallet: user.stellarAddress, + previousStatus, + newStatus: KYCStatus.REJECTED, + reviewerWallet: reviewer?.stellarAddress ?? reviewerId, + reviewerId, + action: "reject", + reason: rejectionReason, }); return res.json({ success: true }); diff --git a/src/routes/admin/revoke-kyc.ts b/src/routes/admin/revoke-kyc.ts new file mode 100644 index 0000000..84716ad --- /dev/null +++ b/src/routes/admin/revoke-kyc.ts @@ -0,0 +1,74 @@ +import { Request, Response } from "express"; +import { DataSource } from "typeorm"; +import { User } from "@/models/User.model"; +import { KYCStatus } from "@/types/enums"; +import { logKYCStatusChange } from "@/lib/kyc-status-log"; +import { logger } from "@/observability/logger"; + +interface RevokeKYCBody { + userId: string; + reviewerId: string; + revocationReason: string; +} + +/** + * Withdraws a previously granted KYC approval. + * + * Revocation returns the user to {@link KYCStatus.PENDING} rather than + * {@link KYCStatus.REJECTED}: the user is no longer cleared to trade, but the + * decision is "needs review again", not "rejected on the merits", and it leaves + * them able to re-submit. Only an approved user can be revoked — revoking + * anything else would be a no-op that still wrote an audit entry. + */ +export async function revokeKYC(req: Request, res: Response, dataSource: DataSource) { + try { + const adminKey = req.headers["x-admin-key"]; + if (adminKey !== process.env.ADMIN_API_KEY) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { userId, reviewerId, revocationReason } = req.body; + + const userRepo = dataSource.getRepository(User); + const user = await userRepo.findOneBy({ id: userId }); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const previousStatus = user.kycStatus; + if (previousStatus !== KYCStatus.APPROVED) { + return res.status(409).json({ + error: { + code: "KYC_NOT_APPROVED", + message: `Cannot revoke KYC for a user whose status is ${previousStatus}.`, + }, + }); + } + + const reviewer = await userRepo.findOneBy({ id: reviewerId }); + + await userRepo.update(userId, { kycStatus: KYCStatus.PENDING }); + + // Logged only after the DB update succeeds, so the audit trail never + // records a decision that didn't actually persist. + logKYCStatusChange(logger, { + wallet: user.stellarAddress, + previousStatus, + newStatus: KYCStatus.PENDING, + reviewerWallet: reviewer?.stellarAddress ?? reviewerId, + reviewerId, + action: "revoke", + reason: revocationReason, + }); + + return res.json({ success: true }); + } catch (err: unknown) { + const appErr = err as { status?: number; code?: string; message?: string }; + return res.status(appErr.status ?? 500).json({ + error: { + code: appErr.code ?? "INTERNAL_ERROR", + message: appErr.message ?? "Internal server error", + }, + }); + } +} diff --git a/src/routes/investment.routes.ts b/src/routes/investment.routes.ts index af8c6bd..ed27df4 100644 --- a/src/routes/investment.routes.ts +++ b/src/routes/investment.routes.ts @@ -1,13 +1,17 @@ -import { Router } from "express"; +import { Router, type RequestHandler } from "express"; import { InvestmentController } from "../controllers/investment.controller"; import { InvestmentService } from "../services/investment.service"; import { createAuthMiddleware } from "../middleware/auth.middleware"; import { createWalletRateLimiter } from "../middleware/rate-limit-wallet.middleware"; +import { checkContractNotPaused } from "../middleware/contract-pause-guard.middleware"; import type { AuthService } from "../services/auth.service"; +import type { ContractGuardService } from "../services/stellar/contract-guard.service"; export interface InvestmentRouterDependencies { investmentService: InvestmentService; authService: AuthService; + contractGuardService?: ContractGuardService; + contractId?: string | null; } // Per-wallet rate limit: max 10 investment submissions per 60 seconds @@ -19,13 +23,21 @@ const investmentRateLimiter = createWalletRateLimiter( export function createInvestmentRouter({ investmentService, authService, + contractGuardService, + contractId = null, }: InvestmentRouterDependencies): Router { const router = Router(); const controller = new InvestmentController(investmentService); const authMiddleware = createAuthMiddleware(authService); + // Only the state-changing endpoint is gated: reading a portfolio during a + // pause is harmless, and blocking it would hide the state of the system. + const pauseGuard: RequestHandler[] = contractGuardService + ? [checkContractNotPaused({ contractGuardService, contractId })] + : []; + // POST /api/v1/investments - Create a new investment commitment - router.post("/", authMiddleware, investmentRateLimiter, controller.createInvestment); + router.post("/", authMiddleware, ...pauseGuard, investmentRateLimiter, controller.createInvestment); // GET /api/v1/investments/dashboard - Investor portfolio aggregate router.get("/dashboard", authMiddleware, controller.getDashboard); diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index 80784d5..d387032 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -68,6 +68,19 @@ const updateInvoiceSchema = Joi.object({ .max(100), }); +const batchPublishSchema = Joi.object({ + invoiceIds: Joi.array() + .items(Joi.string().uuid().required()) + .min(1) + .max(100) + .required() + .messages({ + "array.min": "invoiceIds must contain at least one invoice id", + "array.max": "invoiceIds must contain at most 100 invoice ids", + "string.guid": "invoiceIds must contain valid invoice ids", + }), +}); + const getInvoicesQuerySchema = Joi.object({ page: Joi.number().integer().min(1).default(1), limit: Joi.number().integer().min(1).max(100).default(20), @@ -194,6 +207,18 @@ export function createInvoiceRouter({ controller.createInvoice, ); + // POST /api/v1/invoices/batch-publish - Publish several drafts atomically. + // Declared ahead of the "/:id" routes so "batch-publish" is never matched as + // an invoice id. + router.post( + "/batch-publish", + authenticateJWT, + kycGating, + publishRateLimiter, + validateBody(batchPublishSchema), + controller.batchPublishInvoices, + ); + // GET /api/v1/invoices/:id - Get single invoice router.get("/:id", authenticateJWT, controller.getInvoice); diff --git a/src/routes/settlement.routes.ts b/src/routes/settlement.routes.ts index 32c1700..ba8ce20 100644 --- a/src/routes/settlement.routes.ts +++ b/src/routes/settlement.routes.ts @@ -1,21 +1,31 @@ -import { Router } from "express"; +import { Router, type RequestHandler } from "express"; import { SettlementController } from "../controllers/settlement.controller"; import type { SettlementService } from "../services/settlement.service"; import { authenticateJWT } from "../middleware/auth.middleware"; +import { checkContractNotPaused } from "../middleware/contract-pause-guard.middleware"; +import type { ContractGuardService } from "../services/stellar/contract-guard.service"; export interface SettlementRouterDependencies { settlementService: SettlementService; + contractGuardService?: ContractGuardService; + contractId?: string | null; } export function createSettlementRouter({ settlementService, + contractGuardService, + contractId = null, }: SettlementRouterDependencies): Router { const router = Router(); const controller = new SettlementController(settlementService); + const pauseGuard: RequestHandler[] = contractGuardService + ? [checkContractNotPaused({ contractGuardService, contractId })] + : []; + // POST /api/v1/settlements/:invoiceId - Settle a funded invoice and // distribute pro-rata returns to its confirmed investors - router.post("/:invoiceId", authenticateJWT, controller.settleInvoice); + router.post("/:invoiceId", authenticateJWT, ...pauseGuard, controller.settleInvoice); return router; } diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index f77eec2..a437143 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -71,6 +71,27 @@ export interface PublishInvoiceInput { sellerId: string; } +export interface BatchPublishInvoicesInput { + invoiceIds: string[]; + sellerId: string; +} + +/** Why a single invoice in a batch could not be published. */ +export interface BatchPublishRejection { + invoiceId: string; + code: + | "invoice_not_found" + | "unauthorized_invoice_access" + | "invalid_status_transition" + | "invoice_not_publishable"; + message: string; +} + +export interface BatchPublishInvoicesResult { + published: InvoiceDTO[]; + count: number; +} + export interface CommitmentDTO { investor_wallet: string; amount: string; @@ -385,6 +406,138 @@ export class InvoiceService { return this.toDTO(updated); } + /** + * Publish several draft invoices in one atomic step. + * + * Sellers with large receivable books were publishing twenty invoices with + * twenty round trips, and a failure halfway through left them with a + * half-published book and no clear way to tell which half. This is all or + * nothing: every invoice is validated first, and if any one of them fails + * the whole batch is rejected and nothing is written. + * + * The rejection list names every invoice that failed and why, so the seller + * can fix all of them in one pass rather than rediscovering the next problem + * on each retry. + */ + async publishInvoicesBatch( + input: BatchPublishInvoicesInput, + ): Promise { + const { invoiceIds, sellerId } = input; + + if (invoiceIds.length === 0) { + throw new ServiceError("empty_batch", "At least one invoice id is required", 400); + } + + const uniqueIds = [...new Set(invoiceIds)]; + + if (!this.dataSource) { + throw new ServiceError( + "batch_publish_unavailable", + "Batch publishing requires a database connection", + 503, + ); + } + + // The seller's wallet is captured alongside each invoice because the + // lifecycle log needs it after the write, once the relation may no longer + // be loaded on the saved entity. + const publishable: Array<{ invoice: Invoice; sellerWallet: string }> = []; + const rejections: BatchPublishRejection[] = []; + + for (const invoiceId of uniqueIds) { + const invoice = await this.invoiceRepository.findOne({ + where: { id: invoiceId }, + relations: ["seller"], + }); + + if (!invoice) { + rejections.push({ + invoiceId, + code: "invoice_not_found", + message: "Invoice not found", + }); + continue; + } + + if (invoice.sellerId !== sellerId) { + // Reported the same way as a missing invoice would be, so the response + // does not confirm that someone else's invoice id exists. + rejections.push({ + invoiceId, + code: "unauthorized_invoice_access", + message: "Invoice not found", + }); + continue; + } + + const seller = invoice.seller as unknown as User; + if (!seller || seller.kycStatus !== KYCStatus.APPROVED) { + throw new ServiceError( + "kyc_approval_required", + "KYC approval is required to publish invoices", + 403, + ); + } + + if (invoice.status !== InvoiceStatus.DRAFT) { + rejections.push({ + invoiceId, + code: "invalid_status_transition", + message: `Cannot publish an invoice in status ${invoice.status}; only drafts can be published`, + }); + continue; + } + + const validationErrors = validateInvoiceForPublish(invoice); + if (validationErrors.length > 0) { + rejections.push({ + invoiceId, + code: "invoice_not_publishable", + message: `Invoice failed pre-publish validation: ${validationErrors.map((e) => e.message).join(" ")}`, + }); + continue; + } + + publishable.push({ invoice, sellerWallet: seller.stellarAddress }); + } + + if (rejections.length > 0) { + throw new ServiceError( + "batch_publish_rejected", + `${rejections.length} of ${uniqueIds.length} invoices cannot be published; no invoices were changed`, + 400, + { rejections }, + ); + } + + // Nothing is written until every invoice has passed, so a failure inside + // the transaction rolls the whole batch back rather than leaving a partial + // publish behind. + const saved = await this.dataSource.transaction(async (manager) => { + const results: Invoice[] = []; + for (const { invoice } of publishable) { + invoice.status = InvoiceStatus.PUBLISHED; + results.push(await manager.save(invoice)); + } + return results; + }); + + publishable.forEach(({ sellerWallet }, index) => { + logInvoiceTransition(logger, { + invoiceId: saved[index].id, + fromState: InvoiceStatus.DRAFT, + toState: InvoiceStatus.PUBLISHED, + actorWallet: sellerWallet, + reason: "seller_batch_published", + }); + }); + + return { + published: saved.map((invoice) => this.toDTO(invoice)), + count: saved.length, + }; + } + /** * Upload document (IPFS) */ diff --git a/src/services/stellar/contract-guard.service.ts b/src/services/stellar/contract-guard.service.ts new file mode 100644 index 0000000..999aad3 --- /dev/null +++ b/src/services/stellar/contract-guard.service.ts @@ -0,0 +1,215 @@ +import { Address, xdr, scValToNative } from "stellar-sdk"; +import type { AppLogger } from "../../observability/logger"; +import { logger as globalLogger } from "../../observability/logger"; + +type FetchLike = typeof fetch; +type Clock = () => number; + +/** + * How long a pause reading stays usable before the guard goes back to the RPC. + * + * Short enough that a pause takes effect within seconds, long enough that a + * burst of API traffic collapses into one RPC call per contract rather than one + * per request — which is what would otherwise get the node to throttle us. + */ +export const PAUSE_STATE_TTL_MS = 15_000; + +/** Contract storage symbol holding the emergency pause flag. */ +const PAUSED_STORAGE_KEY = "Paused"; + +export interface ContractGuardServiceDependencies { + /** Soroban JSON-RPC endpoint, e.g. https://soroban-testnet.stellar.org. */ + rpcUrl: string; + fetchFn?: FetchLike; + logger?: AppLogger; + /** Cache lifetime override, primarily for tests. */ + ttlMs?: number; + /** Injectable clock so tests can advance time without waiting. */ + now?: Clock; + /** + * Decodes a base64 `ContractData` ledger entry into the flag it holds. + * Injectable so tests can exercise the caching and failure behaviour without + * hand-assembling ledger-entry XDR. + */ + decodeEntry?: (entryXdr: string) => boolean; +} + +interface CachedPauseState { + paused: boolean; + readAt: number; +} + +interface SorobanLedgerEntryResult { + key?: string; + xdr?: string; +} + +interface SorobanGetLedgerEntriesResponse { + result?: { + entries?: SorobanLedgerEntryResult[] | null; + }; + error?: { + code?: number; + message?: string; + }; +} + +/** + * Reads and caches the on-chain emergency pause flag for Soroban contracts. + * + * The contracts expose `pause()` / `unpause()` which flip a `Paused` entry in + * persistent contract storage. This service reads that entry directly through + * `getLedgerEntries`, so it observes a pause the moment the admin's transaction + * lands rather than waiting for an event pipeline. + * + * ## Absent entry means "not paused" + * + * A contract that has never been paused has no `Paused` storage entry at all. + * That is indistinguishable from `false` and is treated as such. + * + * ## Behaviour when the RPC is unreachable + * + * Falling back to "paused" on every RPC blip would take the whole API down for + * a network wobble; falling back to "not paused" would let trades through + * during a real incident. The compromise: serve the last reading we have, even + * an expired one, and only assume "not paused" when we have never had one. Both + * fallbacks are logged at warn level so the gap is visible in an incident. + */ +export class ContractGuardService { + private readonly rpcUrl: string; + private readonly fetchFn: FetchLike; + private readonly logger: AppLogger; + private readonly ttlMs: number; + private readonly now: Clock; + private readonly decodeEntry: (entryXdr: string) => boolean; + private readonly cache = new Map(); + /** In-flight reads, so concurrent requests share one RPC round trip. */ + private readonly inFlight = new Map>(); + + constructor(dependencies: ContractGuardServiceDependencies) { + if (!dependencies.rpcUrl) { + throw new Error("rpcUrl is required."); + } + this.rpcUrl = dependencies.rpcUrl; + this.fetchFn = dependencies.fetchFn ?? fetch; + this.logger = dependencies.logger ?? globalLogger; + this.ttlMs = dependencies.ttlMs ?? PAUSE_STATE_TTL_MS; + this.now = dependencies.now ?? Date.now; + this.decodeEntry = dependencies.decodeEntry ?? decodePausedEntry; + } + + /** + * Returns whether `contractId` is currently paused, using the cached reading + * when it is still fresh. + */ + async checkContractPauseState(contractId: string): Promise { + const cached = this.cache.get(contractId); + if (cached && this.now() - cached.readAt < this.ttlMs) { + return cached.paused; + } + + const existing = this.inFlight.get(contractId); + if (existing) { + return existing; + } + + const read = this.readAndCache(contractId).finally(() => { + this.inFlight.delete(contractId); + }); + this.inFlight.set(contractId, read); + return read; + } + + /** Drops all cached readings. Used by tests and by admin-triggered refreshes. */ + clearCache(): void { + this.cache.clear(); + } + + private async readAndCache(contractId: string): Promise { + try { + const paused = await this.fetchPauseState(contractId); + this.cache.set(contractId, { paused, readAt: this.now() }); + return paused; + } catch (error) { + const stale = this.cache.get(contractId); + if (stale) { + this.logger.warn("Falling back to stale contract pause state", { + contract_id: contractId, + stale_paused: stale.paused, + stale_age_ms: this.now() - stale.readAt, + reason: error instanceof Error ? error.message : String(error), + }); + return stale.paused; + } + + this.logger.warn("Contract pause state unavailable; assuming not paused", { + contract_id: contractId, + reason: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + private async fetchPauseState(contractId: string): Promise { + const response = await this.fetchFn(this.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "getLedgerEntries", + params: { keys: [buildPausedLedgerKey(contractId)] }, + }), + }); + + if (!response.ok) { + throw new Error(`Soroban RPC responded ${response.status}`); + } + + const body = (await response.json()) as SorobanGetLedgerEntriesResponse; + if (body.error) { + throw new Error(body.error.message ?? "Soroban RPC returned an error"); + } + + const entry = body.result?.entries?.[0]; + if (!entry?.xdr) { + // No `Paused` entry has ever been written for this contract. + return false; + } + + return this.decodeEntry(entry.xdr); + } +} + +/** + * Builds the base64 ledger key for a contract's persistent `Paused` entry. + */ +export function buildPausedLedgerKey(contractId: string): string { + const key = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(contractId).toScAddress(), + key: xdr.ScVal.scvSymbol(PAUSED_STORAGE_KEY), + durability: xdr.ContractDataDurability.persistent(), + }), + ); + return key.toXDR("base64"); +} + +/** + * Decodes a `ContractData` ledger entry into the boolean it holds. + * + * Anything that is not a boolean `true` reads as not paused — a contract that + * stores something unexpected under `Paused` is a bug, but it is not grounds + * for refusing every payment in the system. + */ +export function decodePausedEntry(entryXdr: string): boolean { + const entryData = xdr.LedgerEntryData.fromXDR(entryXdr, "base64"); + const value = scValToNative(entryData.contractData().val()); + return value === true; +} + +export function createContractGuardService( + dependencies: ContractGuardServiceDependencies, +): ContractGuardService { + return new ContractGuardService(dependencies); +} diff --git a/tests/funding-deadline-validator.test.ts b/tests/funding-deadline-validator.test.ts new file mode 100644 index 0000000..6ddf482 --- /dev/null +++ b/tests/funding-deadline-validator.test.ts @@ -0,0 +1,190 @@ +import { + MIN_LEAD_TIME_MS, + validateFundingDeadline, + validateInvoiceForPublish, +} from "../src/lib/validate-invoice-for-publish"; +import { Invoice } from "../src/models/Invoice.model"; +import { InvoiceStatus } from "../src/types/enums"; + +/** A fixed instant to measure every boundary against. */ +const NOW = new Date("2026-03-01T12:00:00.000Z"); + +const HOUR_MS = 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; + +function offsetFromNow(ms: number): Date { + return new Date(NOW.getTime() + ms); +} + +function createInvoice(overrides: Partial = {}): Invoice { + return { + id: "invoice-1", + sellerId: "seller-1", + invoiceNumber: "INV-001", + customerName: "Customer A", + amount: "1000.0000", + discountRate: "5.00", + netAmount: "950.0000", + dueDate: offsetFromNow(48 * HOUR_MS), + ipfsHash: "QmTestHash", + riskScore: null, + status: InvoiceStatus.DRAFT, + smartContractId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + seller: overrides.seller as Invoice["seller"], + investments: overrides.investments ?? [], + transactions: overrides.transactions ?? [], + ...overrides, + } as Invoice; +} + +describe("validateFundingDeadline", () => { + it("uses a 24 hour minimum lead time", () => { + expect(MIN_LEAD_TIME_MS).toBe(24 * HOUR_MS); + }); + + describe("boundary around the 24 hour cutoff", () => { + it("accepts a deadline exactly 24 hours away", () => { + expect(validateFundingDeadline(offsetFromNow(MIN_LEAD_TIME_MS), NOW)).toBeNull(); + }); + + it("rejects a deadline 23 hours 59 minutes away as too soon", () => { + const deadline = offsetFromNow(23 * HOUR_MS + 59 * MINUTE_MS); + expect(validateFundingDeadline(deadline, NOW)).toMatchObject({ + field: "dueDate", + code: "DUE_DATE_TOO_SOON", + }); + }); + + it("rejects a deadline one millisecond inside the window", () => { + const deadline = offsetFromNow(MIN_LEAD_TIME_MS - 1); + expect(validateFundingDeadline(deadline, NOW)).toMatchObject({ + code: "DUE_DATE_TOO_SOON", + }); + }); + + it("accepts a deadline one millisecond outside the window", () => { + expect(validateFundingDeadline(offsetFromNow(MIN_LEAD_TIME_MS + 1), NOW)).toBeNull(); + }); + + it("accepts a deadline 7 days away", () => { + expect(validateFundingDeadline(offsetFromNow(7 * 24 * HOUR_MS), NOW)).toBeNull(); + }); + }); + + describe("deadlines at or before now", () => { + it("rejects a deadline one hour in the past", () => { + expect(validateFundingDeadline(offsetFromNow(-HOUR_MS), NOW)).toMatchObject({ + field: "dueDate", + code: "DUE_DATE_IN_PAST", + }); + }); + + it("rejects a deadline exactly at now", () => { + expect(validateFundingDeadline(new Date(NOW), NOW)).toMatchObject({ + code: "DUE_DATE_IN_PAST", + }); + }); + + it("rejects a deadline one millisecond in the past", () => { + expect(validateFundingDeadline(offsetFromNow(-1), NOW)).toMatchObject({ + code: "DUE_DATE_IN_PAST", + }); + }); + + it("distinguishes a past deadline from a merely tight one", () => { + const past = validateFundingDeadline(offsetFromNow(-HOUR_MS), NOW); + const tight = validateFundingDeadline(offsetFromNow(HOUR_MS), NOW); + expect(past?.code).toBe("DUE_DATE_IN_PAST"); + expect(tight?.code).toBe("DUE_DATE_TOO_SOON"); + expect(past?.code).not.toBe(tight?.code); + }); + }); + + it("treats an unparseable deadline as in the past rather than passing it through", () => { + expect(validateFundingDeadline("not-a-date", NOW)).toMatchObject({ + code: "DUE_DATE_IN_PAST", + }); + }); + + it("accepts an ISO string deadline as well as a Date", () => { + const iso = offsetFromNow(48 * HOUR_MS).toISOString(); + expect(validateFundingDeadline(iso, NOW)).toBeNull(); + }); + + describe("clock source", () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it("reads the server clock when no reference time is supplied", () => { + jest.useFakeTimers().setSystemTime(NOW); + + // 25 hours past NOW: comfortably valid against the server clock. + const deadline = offsetFromNow(25 * HOUR_MS); + expect(validateFundingDeadline(deadline)).toBeNull(); + + // Advance the server clock past the deadline; the same deadline is now + // in the past. Nothing about the input changed, only the clock. + jest.setSystemTime(offsetFromNow(26 * HOUR_MS)); + expect(validateFundingDeadline(deadline)).toMatchObject({ + code: "DUE_DATE_IN_PAST", + }); + }); + + it("ignores a client-supplied timestamp carried on the request payload", () => { + jest.useFakeTimers().setSystemTime(NOW); + + // A seller backdating "now" on the payload must not make an expired + // deadline publishable — the validator never reads request fields. + const expiredDeadline = offsetFromNow(-HOUR_MS); + const payload = { + dueDate: expiredDeadline, + now: offsetFromNow(-48 * HOUR_MS).toISOString(), + currentTime: offsetFromNow(-48 * HOUR_MS).toISOString(), + clientTimestamp: offsetFromNow(-48 * HOUR_MS).getTime(), + }; + + expect(validateFundingDeadline(payload.dueDate)).toMatchObject({ + code: "DUE_DATE_IN_PAST", + }); + }); + }); +}); + +describe("validateInvoiceForPublish deadline reporting", () => { + it("reports DUE_DATE_TOO_SOON for a deadline inside the window", () => { + const invoice = createInvoice({ dueDate: offsetFromNow(HOUR_MS) }); + const errors = validateInvoiceForPublish(invoice, NOW); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ field: "dueDate", code: "DUE_DATE_TOO_SOON" }); + }); + + it("reports DUE_DATE_IN_PAST for an expired deadline", () => { + const invoice = createInvoice({ dueDate: offsetFromNow(-HOUR_MS) }); + const errors = validateInvoiceForPublish(invoice, NOW); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ field: "dueDate", code: "DUE_DATE_IN_PAST" }); + }); + + it("reports no deadline error at exactly 24 hours or 7 days out", () => { + for (const offset of [MIN_LEAD_TIME_MS, 7 * 24 * HOUR_MS]) { + const invoice = createInvoice({ dueDate: offsetFromNow(offset) }); + expect(validateInvoiceForPublish(invoice, NOW)).toEqual([]); + } + }); + + it("still reports the deadline alongside other field failures", () => { + const invoice = createInvoice({ + amount: "1.0000", + dueDate: offsetFromNow(-HOUR_MS), + ipfsHash: null, + }); + const codes = validateInvoiceForPublish(invoice, NOW).map((e) => e.code); + expect(codes).toEqual( + expect.arrayContaining(["FACE_VALUE_TOO_LOW", "DUE_DATE_IN_PAST", "MISSING_DOCUMENT"]), + ); + }); +}); diff --git a/tests/unit/contract-guard.service.test.ts b/tests/unit/contract-guard.service.test.ts new file mode 100644 index 0000000..31179c2 --- /dev/null +++ b/tests/unit/contract-guard.service.test.ts @@ -0,0 +1,236 @@ +import { + ContractGuardService, + PAUSE_STATE_TTL_MS, + buildPausedLedgerKey, +} from "@/services/stellar/contract-guard.service"; + +const RPC_URL = "https://soroban-testnet.example/rpc"; +const CONTRACT_ID = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; + +/** + * Stand-in for a base64 ledger entry. The real decoder is XDR; these tests + * inject a trivial one so they exercise the caching, concurrency and failure + * behaviour rather than re-testing the SDK's codec. + */ +function pausedEntryXdr(paused: boolean): string { + return paused ? "PAUSED" : "NOT_PAUSED"; +} + +const decodeEntry = (entryXdr: string) => entryXdr === "PAUSED"; + +function jsonResponse(body: unknown, ok = true, status = 200) { + return { + ok, + status, + json: async () => body, + } as unknown as Response; +} + +function entriesResponse(paused: boolean | null) { + return jsonResponse({ + jsonrpc: "2.0", + id: 1, + result: { + entries: paused === null ? [] : [{ key: "k", xdr: pausedEntryXdr(paused) }], + }, + }); +} + +describe("ContractGuardService", () => { + let currentTime: number; + const now = () => currentTime; + + beforeEach(() => { + currentTime = 1_700_000_000_000; + }); + + function createService(fetchFn: jest.Mock, ttlMs?: number) { + return new ContractGuardService({ + rpcUrl: RPC_URL, + fetchFn: fetchFn as unknown as typeof fetch, + now, + ttlMs, + decodeEntry, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: jest.fn(), + } as never, + }); + } + + it("requires an rpcUrl", () => { + expect(() => new ContractGuardService({ rpcUrl: "" })).toThrow("rpcUrl is required."); + }); + + describe("buildPausedLedgerKey", () => { + it("produces a deterministic base64 key for a contract", () => { + const key = buildPausedLedgerKey(CONTRACT_ID); + expect(key).toEqual(buildPausedLedgerKey(CONTRACT_ID)); + expect(key).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + expect(key.length).toBeGreaterThan(0); + }); + + it("produces a different key for a different contract", () => { + const other = "CBQHNAXSI55GX2GN6D67GK7BHVPSLJUGZQEU7WJ5LKR5PNUCGLIMAO4K"; + expect(buildPausedLedgerKey(CONTRACT_ID)).not.toEqual(buildPausedLedgerKey(other)); + }); + }); + + describe("reading on-chain state", () => { + it("reports paused when the contract stores true", async () => { + const fetchFn = jest.fn().mockResolvedValue(entriesResponse(true)); + const service = createService(fetchFn); + + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(true); + }); + + it("reports not paused when the contract stores false", async () => { + const fetchFn = jest.fn().mockResolvedValue(entriesResponse(false)); + const service = createService(fetchFn); + + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(false); + }); + + it("treats a missing Paused entry as not paused", async () => { + const fetchFn = jest.fn().mockResolvedValue(entriesResponse(null)); + const service = createService(fetchFn); + + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(false); + }); + + it("queries getLedgerEntries with the contract's persistent Paused key", async () => { + const fetchFn = jest.fn().mockResolvedValue(entriesResponse(false)); + const service = createService(fetchFn); + + await service.checkContractPauseState(CONTRACT_ID); + + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe(RPC_URL); + const body = JSON.parse(init.body); + expect(body.method).toBe("getLedgerEntries"); + expect(body.params.keys).toEqual([buildPausedLedgerKey(CONTRACT_ID)]); + }); + }); + + describe("caching", () => { + it("defaults to a 15 second TTL", () => { + expect(PAUSE_STATE_TTL_MS).toBe(15_000); + }); + + it("serves repeat calls inside the TTL from cache without hitting the RPC", async () => { + const fetchFn = jest.fn().mockResolvedValue(entriesResponse(true)); + const service = createService(fetchFn); + + await service.checkContractPauseState(CONTRACT_ID); + currentTime += PAUSE_STATE_TTL_MS - 1; + await service.checkContractPauseState(CONTRACT_ID); + + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("re-reads once the TTL has elapsed", async () => { + const fetchFn = jest + .fn() + .mockResolvedValueOnce(entriesResponse(false)) + .mockResolvedValueOnce(entriesResponse(true)); + const service = createService(fetchFn); + + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(false); + currentTime += PAUSE_STATE_TTL_MS; + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(true); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("caches per contract rather than globally", async () => { + const other = "CBQHNAXSI55GX2GN6D67GK7BHVPSLJUGZQEU7WJ5LKR5PNUCGLIMAO4K"; + const fetchFn = jest.fn().mockResolvedValue(entriesResponse(false)); + const service = createService(fetchFn); + + await service.checkContractPauseState(CONTRACT_ID); + await service.checkContractPauseState(other); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("collapses concurrent reads of the same contract into one RPC call", async () => { + const fetchFn = jest.fn().mockResolvedValue(entriesResponse(true)); + const service = createService(fetchFn); + + const results = await Promise.all([ + service.checkContractPauseState(CONTRACT_ID), + service.checkContractPauseState(CONTRACT_ID), + service.checkContractPauseState(CONTRACT_ID), + ]); + + expect(results).toEqual([true, true, true]); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("forgets everything when the cache is cleared", async () => { + const fetchFn = jest.fn().mockResolvedValue(entriesResponse(false)); + const service = createService(fetchFn); + + await service.checkContractPauseState(CONTRACT_ID); + service.clearCache(); + await service.checkContractPauseState(CONTRACT_ID); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + }); + + describe("degraded RPC", () => { + it("serves the last known reading when a refresh fails", async () => { + const fetchFn = jest + .fn() + .mockResolvedValueOnce(entriesResponse(true)) + .mockRejectedValueOnce(new Error("connection reset")); + const service = createService(fetchFn); + + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(true); + currentTime += PAUSE_STATE_TTL_MS; + + // The refresh fails, but the last thing we knew was "paused", so the + // guard keeps blocking rather than opening up during an incident. + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(true); + }); + + it("assumes not paused when no reading has ever succeeded", async () => { + const fetchFn = jest.fn().mockRejectedValue(new Error("connection reset")); + const service = createService(fetchFn); + + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(false); + }); + + it("treats a non-2xx RPC response as a failure", async () => { + const fetchFn = jest.fn().mockResolvedValue(jsonResponse({}, false, 502)); + const service = createService(fetchFn); + + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(false); + }); + + it("treats a JSON-RPC error payload as a failure", async () => { + const fetchFn = jest + .fn() + .mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: 1, error: { message: "boom" } })); + const service = createService(fetchFn); + + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(false); + }); + + it("does not cache a failed read as a negative result", async () => { + const fetchFn = jest + .fn() + .mockRejectedValueOnce(new Error("connection reset")) + .mockResolvedValueOnce(entriesResponse(true)); + const service = createService(fetchFn); + + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(false); + // No TTL advance: a failure must not have poisoned the cache with false. + await expect(service.checkContractPauseState(CONTRACT_ID)).resolves.toBe(true); + }); + }); +}); diff --git a/tests/unit/contract-pause-guard.middleware.test.ts b/tests/unit/contract-pause-guard.middleware.test.ts new file mode 100644 index 0000000..3d6a44a --- /dev/null +++ b/tests/unit/contract-pause-guard.middleware.test.ts @@ -0,0 +1,130 @@ +import { checkContractNotPaused } from "@/middleware/contract-pause-guard.middleware"; +import type { ContractGuardService } from "@/services/stellar/contract-guard.service"; + +const CONTRACT_ID = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; + +function createContext() { + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + const req = { method: "POST", originalUrl: "/api/v1/investments", path: "/" }; + const next = jest.fn(); + return { req: req as never, res: res as never, next, resMock: res }; +} + +function guardService(impl: jest.Mock): ContractGuardService { + return { checkContractPauseState: impl } as unknown as ContractGuardService; +} + +const silentLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: jest.fn(), +} as never; + +describe("checkContractNotPaused", () => { + it("lets the request through when the contract is not paused", async () => { + const check = jest.fn().mockResolvedValue(false); + const { req, res, next, resMock } = createContext(); + + await checkContractNotPaused({ + contractGuardService: guardService(check), + contractId: CONTRACT_ID, + logger: silentLogger, + })(req, res, next); + + expect(check).toHaveBeenCalledWith(CONTRACT_ID); + expect(next).toHaveBeenCalledWith(); + expect(resMock.status).not.toHaveBeenCalled(); + }); + + it("rejects with 503 and a CONTRACT_PAUSED error when the contract is paused", async () => { + const check = jest.fn().mockResolvedValue(true); + const { req, res, next, resMock } = createContext(); + + await checkContractNotPaused({ + contractGuardService: guardService(check), + contractId: CONTRACT_ID, + logger: silentLogger, + })(req, res, next); + + expect(resMock.status).toHaveBeenCalledWith(503); + expect(resMock.json).toHaveBeenCalledWith({ + success: false, + error: { + code: "CONTRACT_PAUSED", + message: "Smart contract operations are currently paused by administration.", + }, + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("logs the blocked request so a pause is visible in operations", async () => { + const warn = jest.fn(); + const check = jest.fn().mockResolvedValue(true); + const { req, res, next } = createContext(); + + await checkContractNotPaused({ + contractGuardService: guardService(check), + contractId: CONTRACT_ID, + logger: { ...(silentLogger as object), warn } as never, + })(req, res, next); + + expect(warn).toHaveBeenCalledWith("Request blocked: smart contract is paused", { + contract_id: CONTRACT_ID, + method: "POST", + path: "/api/v1/investments", + }); + }); + + it("is inert when no contract id is configured", async () => { + const check = jest.fn(); + const { req, res, next, resMock } = createContext(); + + await checkContractNotPaused({ + contractGuardService: guardService(check), + contractId: null, + logger: silentLogger, + })(req, res, next); + + expect(check).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + expect(resMock.status).not.toHaveBeenCalled(); + }); + + it("forwards an unexpected guard failure to the error handler rather than allowing the request", async () => { + const failure = new Error("guard exploded"); + const check = jest.fn().mockRejectedValue(failure); + const { req, res, next, resMock } = createContext(); + + await checkContractNotPaused({ + contractGuardService: guardService(check), + contractId: CONTRACT_ID, + logger: silentLogger, + })(req, res, next); + + expect(next).toHaveBeenCalledWith(failure); + expect(resMock.status).not.toHaveBeenCalled(); + }); + + it("re-checks on every request so an unpause takes effect without a restart", async () => { + const check = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); + const middleware = checkContractNotPaused({ + contractGuardService: guardService(check), + contractId: CONTRACT_ID, + logger: silentLogger, + }); + + const blocked = createContext(); + await middleware(blocked.req, blocked.res, blocked.next); + expect(blocked.resMock.status).toHaveBeenCalledWith(503); + + const allowed = createContext(); + await middleware(allowed.req, allowed.res, allowed.next); + expect(allowed.next).toHaveBeenCalledWith(); + expect(allowed.resMock.status).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/invoice-batch-publish.test.ts b/tests/unit/invoice-batch-publish.test.ts new file mode 100644 index 0000000..350ebcc --- /dev/null +++ b/tests/unit/invoice-batch-publish.test.ts @@ -0,0 +1,270 @@ +import { InvoiceService } from "@/services/invoice.service"; +import { ServiceError } from "@/utils/service-error"; +import { Invoice } from "@/models/Invoice.model"; +import { InvoiceStatus, KYCStatus } from "@/types/enums"; + +const SELLER_ID = "seller-1"; +const OTHER_SELLER_ID = "seller-2"; + +function approvedSeller(id = SELLER_ID) { + return { id, kycStatus: KYCStatus.APPROVED, stellarAddress: "GSELLERWALLET0001" }; +} + +function futureDate(hoursFromNow: number): Date { + return new Date(Date.now() + hoursFromNow * 60 * 60 * 1000); +} + +/** A publishable draft: approved seller, valid amount, document, 48h runway. */ +function draftInvoice(id: string, overrides: Partial = {}): Invoice { + return { + id, + sellerId: SELLER_ID, + invoiceNumber: `INV-${id}`, + customerName: "Customer A", + amount: "1000.0000", + discountRate: "5.00", + netAmount: "950.0000", + dueDate: futureDate(48), + ipfsHash: "QmTestHash", + riskScore: null, + status: InvoiceStatus.DRAFT, + smartContractId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + seller: approvedSeller(), + ...overrides, + } as unknown as Invoice; +} + +describe("InvoiceService.publishInvoicesBatch", () => { + let repository: any; + let ipfsService: any; + let dataSource: any; + let managerSave: jest.Mock; + let transactionCommitted: boolean; + let service: InvoiceService; + + /** Stub the repository so `findOne` resolves each invoice by id. */ + function stubInvoices(invoices: Invoice[]) { + repository.findOne.mockImplementation(async ({ where }: { where: { id: string } }) => { + return invoices.find((invoice) => invoice.id === where.id) ?? null; + }); + } + + beforeEach(() => { + transactionCommitted = false; + managerSave = jest.fn(async (invoice: Invoice) => invoice); + + repository = { + findOne: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + save: jest.fn(), + count: jest.fn(), + create: jest.fn(), + }; + ipfsService = { uploadFile: jest.fn() }; + + dataSource = { + transaction: jest.fn(async (work: (manager: unknown) => Promise) => { + const result = await work({ save: managerSave }); + transactionCommitted = true; + return result; + }), + }; + + service = new InvoiceService({ + invoiceRepository: repository, + ipfsService, + dataSource, + }); + }); + + describe("happy path", () => { + it("publishes every draft in the batch inside a single transaction", async () => { + const invoices = [draftInvoice("a"), draftInvoice("b"), draftInvoice("c")]; + stubInvoices(invoices); + + const result = await service.publishInvoicesBatch({ + invoiceIds: ["a", "b", "c"], + sellerId: SELLER_ID, + }); + + expect(result.count).toBe(3); + expect(result.published.map((i) => i.id)).toEqual(["a", "b", "c"]); + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(managerSave).toHaveBeenCalledTimes(3); + for (const invoice of invoices) { + expect(invoice.status).toBe(InvoiceStatus.PUBLISHED); + } + }); + + it("deduplicates repeated ids so an invoice is published once", async () => { + stubInvoices([draftInvoice("a")]); + + const result = await service.publishInvoicesBatch({ + invoiceIds: ["a", "a", "a"], + sellerId: SELLER_ID, + }); + + expect(result.count).toBe(1); + expect(managerSave).toHaveBeenCalledTimes(1); + }); + + it("publishes a single-invoice batch", async () => { + stubInvoices([draftInvoice("a")]); + + await expect( + service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }), + ).resolves.toMatchObject({ count: 1 }); + }); + }); + + describe("atomicity", () => { + /** + * The whole point of the endpoint: one bad invoice must not leave the + * seller with a half-published book. + */ + it("writes nothing when any invoice in the batch is invalid", async () => { + stubInvoices([ + draftInvoice("a"), + draftInvoice("b", { status: InvoiceStatus.PUBLISHED }), + draftInvoice("c"), + ]); + + await expect( + service.publishInvoicesBatch({ invoiceIds: ["a", "b", "c"], sellerId: SELLER_ID }), + ).rejects.toBeInstanceOf(ServiceError); + + expect(dataSource.transaction).not.toHaveBeenCalled(); + expect(managerSave).not.toHaveBeenCalled(); + }); + + it("leaves the untouched invoices as drafts when the batch is rejected", async () => { + const good = draftInvoice("a"); + stubInvoices([good, draftInvoice("b", { ipfsHash: null })]); + + await expect( + service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }), + ).rejects.toBeInstanceOf(ServiceError); + + expect(good.status).toBe(InvoiceStatus.DRAFT); + }); + + it("propagates a mid-transaction database failure and does not commit", async () => { + stubInvoices([draftInvoice("a"), draftInvoice("b")]); + managerSave + .mockImplementationOnce(async (invoice: Invoice) => invoice) + .mockImplementationOnce(async () => { + throw new Error("deadlock detected"); + }); + + await expect( + service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }), + ).rejects.toThrow("deadlock detected"); + + expect(transactionCommitted).toBe(false); + }); + }); + + describe("rejection reporting", () => { + it("names every failing invoice, not just the first", async () => { + stubInvoices([ + draftInvoice("a"), + draftInvoice("b", { status: InvoiceStatus.PUBLISHED }), + draftInvoice("c", { ipfsHash: null }), + draftInvoice("d", { dueDate: futureDate(-1) }), + ]); + + const error = (await service + .publishInvoicesBatch({ invoiceIds: ["a", "b", "c", "d"], sellerId: SELLER_ID }) + .catch((e) => e)) as ServiceError; + + expect(error).toBeInstanceOf(ServiceError); + expect(error.code).toBe("batch_publish_rejected"); + expect(error.statusCode).toBe(400); + + const rejections = (error.details as { rejections: Array<{ invoiceId: string; code: string }> }) + .rejections; + expect(rejections.map((r) => r.invoiceId).sort()).toEqual(["b", "c", "d"]); + expect(rejections.find((r) => r.invoiceId === "b")?.code).toBe("invalid_status_transition"); + expect(rejections.find((r) => r.invoiceId === "c")?.code).toBe("invoice_not_publishable"); + expect(rejections.find((r) => r.invoiceId === "d")?.code).toBe("invoice_not_publishable"); + }); + + it("reports a missing invoice", async () => { + stubInvoices([draftInvoice("a")]); + + const error = (await service + .publishInvoicesBatch({ invoiceIds: ["a", "missing"], sellerId: SELLER_ID }) + .catch((e) => e)) as ServiceError; + + const rejections = (error.details as { rejections: Array<{ invoiceId: string; code: string }> }) + .rejections; + expect(rejections).toEqual([ + { invoiceId: "missing", code: "invoice_not_found", message: "Invoice not found" }, + ]); + }); + + it("rejects an invoice belonging to another seller without confirming it exists", async () => { + stubInvoices([draftInvoice("a"), draftInvoice("b", { sellerId: OTHER_SELLER_ID })]); + + const error = (await service + .publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }) + .catch((e) => e)) as ServiceError; + + const rejections = ( + error.details as { rejections: Array<{ invoiceId: string; code: string; message: string }> } + ).rejections; + expect(rejections).toHaveLength(1); + expect(rejections[0].code).toBe("unauthorized_invoice_access"); + // Same wording as a genuinely missing invoice, so the response does not + // leak whether another seller's id is real. + expect(rejections[0].message).toBe("Invoice not found"); + }); + + it("rejects a draft whose deadline is inside the 24 hour window", async () => { + stubInvoices([draftInvoice("a", { dueDate: futureDate(1) })]); + + const error = (await service + .publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }) + .catch((e) => e)) as ServiceError; + + const rejections = (error.details as { rejections: Array<{ code: string }> }).rejections; + expect(rejections[0].code).toBe("invoice_not_publishable"); + }); + }); + + describe("preconditions", () => { + it("rejects an empty batch", async () => { + await expect( + service.publishInvoicesBatch({ invoiceIds: [], sellerId: SELLER_ID }), + ).rejects.toMatchObject({ code: "empty_batch", statusCode: 400 }); + }); + + it("rejects the whole batch when the seller is not KYC approved", async () => { + stubInvoices([ + draftInvoice("a", { seller: { ...approvedSeller(), kycStatus: KYCStatus.PENDING } as never }), + ]); + + await expect( + service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }), + ).rejects.toMatchObject({ code: "kyc_approval_required", statusCode: 403 }); + + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("reports batch publishing as unavailable without a data source", async () => { + const noDbService = new InvoiceService({ + invoiceRepository: repository, + ipfsService, + }); + stubInvoices([draftInvoice("a")]); + + await expect( + noDbService.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }), + ).rejects.toMatchObject({ code: "batch_publish_unavailable", statusCode: 503 }); + }); + }); +}); diff --git a/tests/unit/kyc-admin-routes.test.ts b/tests/unit/kyc-admin-routes.test.ts index ba2d0ef..00ddb4a 100644 --- a/tests/unit/kyc-admin-routes.test.ts +++ b/tests/unit/kyc-admin-routes.test.ts @@ -1,16 +1,31 @@ import { approveKYC } from "@/routes/admin/approve-kyc"; import { rejectKYC } from "@/routes/admin/reject-kyc"; +import { revokeKYC } from "@/routes/admin/revoke-kyc"; import { KYCStatus } from "@/types/enums"; import { logger } from "@/observability/logger"; +/** + * Approve, reject and revoke all emit the same structured KYC status-change + * entry (#181). These tests pin the field set, the truncation, the fact that + * `previous_status` is read before the write, and the log-after-persist + * ordering that makes the entry trustworthy as an audit trail. + */ describe("KYC admin routes — structured logging", () => { const ADMIN_KEY = "test-admin-key"; + const REVIEWER = { id: "reviewer-1", stellarAddress: "GREVIEWERWALLET01" }; let mockUserRepo: any; let mockDataSource: any; let req: any; let res: any; let logSpy: jest.SpyInstance; + /** Resolve `findOneBy` per id so the reviewer and subject differ. */ + function stubUsers(users: Array<{ id: string; stellarAddress: string; kycStatus?: KYCStatus }>) { + mockUserRepo.findOneBy.mockImplementation(async ({ id }: { id: string }) => { + return users.find((u) => u.id === id) ?? null; + }); + } + beforeEach(() => { process.env.ADMIN_API_KEY = ADMIN_KEY; @@ -39,9 +54,12 @@ describe("KYC admin routes — structured logging", () => { }); describe("approveKYC", () => { - it("emits an approval log with wallet, decision, reviewer, and timestamp after the DB update", async () => { + it("emits a status-change log with all five audit fields after the DB update", async () => { const callOrder: string[] = []; - mockUserRepo.findOneBy.mockResolvedValue({ id: "user-1", stellarAddress: "GABCDEFGHIJKLMNOP" }); + stubUsers([ + { id: "user-1", stellarAddress: "GABCDEFGHIJKLMNOP", kycStatus: KYCStatus.IN_REVIEW }, + REVIEWER, + ]); mockUserRepo.update.mockImplementation(async () => { callOrder.push("db_update"); }); @@ -49,7 +67,7 @@ describe("KYC admin routes — structured logging", () => { callOrder.push("log"); }); - req.body = { userId: "user-1", reviewerId: "reviewer-1" }; + req.body = { userId: "user-1", reviewerId: REVIEWER.id }; await approveKYC(req, res, mockDataSource); @@ -57,38 +75,80 @@ describe("KYC admin routes — structured logging", () => { expect(logSpy).toHaveBeenCalledTimes(1); const [message, metadata] = logSpy.mock.calls[0]; - expect(message).toBe("KYC approval decision"); + expect(message).toBe("KYC status change"); expect(metadata).toMatchObject({ - wallet_address: "GABC...MNOP", - decision: "approved", - reviewer_id: "reviewer-1", + wallet: "GABC...MNOP", + previous_status: KYCStatus.IN_REVIEW, + new_status: KYCStatus.APPROVED, + reviewer_wallet: "GREV...ET01", + reviewer_id: REVIEWER.id, + action: "approve", }); - expect(typeof metadata.decided_at).toBe("string"); - expect(Object.keys(metadata).sort()).toEqual( - ["decided_at", "decision", "reviewer_id", "wallet_address"].sort(), - ); + expect(typeof metadata.changed_at).toBe("string"); + expect(Number.isNaN(Date.parse(metadata.changed_at))).toBe(false); + // The log must land after the write, never before it. expect(callOrder).toEqual(["db_update", "log"]); }); + it("records the pre-change status, not the new one", async () => { + stubUsers([ + { id: "user-1", stellarAddress: "GABCDEFGHIJKLMNOP", kycStatus: KYCStatus.PENDING }, + REVIEWER, + ]); + req.body = { userId: "user-1", reviewerId: REVIEWER.id }; + + await approveKYC(req, res, mockDataSource); + + const [, metadata] = logSpy.mock.calls[0]; + expect(metadata.previous_status).toBe(KYCStatus.PENDING); + expect(metadata.new_status).toBe(KYCStatus.APPROVED); + expect(metadata.previous_status).not.toBe(metadata.new_status); + }); + + it("falls back to the reviewer id when the reviewer has no user record", async () => { + stubUsers([{ id: "user-1", stellarAddress: "GABCDEFGHIJKLMNOP", kycStatus: KYCStatus.PENDING }]); + req.body = { userId: "user-1", reviewerId: "external-reviewer" }; + + await approveKYC(req, res, mockDataSource); + + const [, metadata] = logSpy.mock.calls[0]; + expect(metadata.reviewer_wallet).toBe("exte...ewer"); + expect(metadata.reviewer_id).toBe("external-reviewer"); + }); + it("does not log when the admin key is invalid", async () => { req.headers["x-admin-key"] = "wrong-key"; - req.body = { userId: "user-1", reviewerId: "reviewer-1" }; + req.body = { userId: "user-1", reviewerId: REVIEWER.id }; await approveKYC(req, res, mockDataSource); expect(res.status).toHaveBeenCalledWith(401); expect(logSpy).not.toHaveBeenCalled(); }); + + it("does not log when the user does not exist", async () => { + stubUsers([REVIEWER]); + req.body = { userId: "missing-user", reviewerId: REVIEWER.id }; + + await approveKYC(req, res, mockDataSource); + + expect(res.status).toHaveBeenCalledWith(404); + expect(mockUserRepo.update).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + }); }); describe("rejectKYC", () => { - it("emits a rejection log including the rejection reason after the DB update", async () => { - mockUserRepo.findOneBy.mockResolvedValue({ id: "user-2", stellarAddress: "GZYXWVUTSRQPONML" }); + it("emits a status-change log including the rejection reason after the DB update", async () => { + stubUsers([ + { id: "user-2", stellarAddress: "GZYXWVUTSRQPONML", kycStatus: KYCStatus.IN_REVIEW }, + REVIEWER, + ]); req.body = { userId: "user-2", - reviewerId: "reviewer-2", + reviewerId: REVIEWER.id, rejectionReason: "Document expired", }; @@ -98,17 +158,135 @@ describe("KYC admin routes — structured logging", () => { expect(logSpy).toHaveBeenCalledTimes(1); const [message, metadata] = logSpy.mock.calls[0]; - expect(message).toBe("KYC rejection decision"); + expect(message).toBe("KYC status change"); + expect(metadata).toMatchObject({ + wallet: "GZYX...ONML", + previous_status: KYCStatus.IN_REVIEW, + new_status: KYCStatus.REJECTED, + reviewer_wallet: "GREV...ET01", + action: "reject", + reason: "Document expired", + }); + expect(typeof metadata.changed_at).toBe("string"); + }); + }); + + describe("revokeKYC", () => { + it("moves an approved user back to pending and logs the change", async () => { + const callOrder: string[] = []; + stubUsers([ + { id: "user-3", stellarAddress: "GQQQQWWWWEEEERRRR", kycStatus: KYCStatus.APPROVED }, + REVIEWER, + ]); + mockUserRepo.update.mockImplementation(async () => { + callOrder.push("db_update"); + }); + logSpy.mockImplementation(() => { + callOrder.push("log"); + }); + + req.body = { + userId: "user-3", + reviewerId: REVIEWER.id, + revocationReason: "Sanctions screening hit", + }; + + await revokeKYC(req, res, mockDataSource); + + expect(mockUserRepo.update).toHaveBeenCalledWith("user-3", { kycStatus: KYCStatus.PENDING }); + expect(logSpy).toHaveBeenCalledTimes(1); + + const [message, metadata] = logSpy.mock.calls[0]; + expect(message).toBe("KYC status change"); expect(metadata).toMatchObject({ - wallet_address: "GZYX...ONML", - decision: "rejected", - reviewer_id: "reviewer-2", - rejection_reason: "Document expired", + wallet: "GQQQ...RRRR", + previous_status: KYCStatus.APPROVED, + new_status: KYCStatus.PENDING, + reviewer_wallet: "GREV...ET01", + action: "revoke", + reason: "Sanctions screening hit", }); - expect(typeof metadata.decided_at).toBe("string"); - expect(Object.keys(metadata).sort()).toEqual( - ["decided_at", "decision", "reviewer_id", "rejection_reason", "wallet_address"].sort(), - ); + expect(typeof metadata.changed_at).toBe("string"); + expect(callOrder).toEqual(["db_update", "log"]); + }); + + it("refuses to revoke a user who is not approved, and logs nothing", async () => { + stubUsers([ + { id: "user-4", stellarAddress: "GQQQQWWWWEEEERRRR", kycStatus: KYCStatus.PENDING }, + REVIEWER, + ]); + req.body = { userId: "user-4", reviewerId: REVIEWER.id, revocationReason: "n/a" }; + + await revokeKYC(req, res, mockDataSource); + + expect(res.status).toHaveBeenCalledWith(409); + expect(mockUserRepo.update).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it("does not log when the admin key is invalid", async () => { + req.headers["x-admin-key"] = "wrong-key"; + req.body = { userId: "user-3", reviewerId: REVIEWER.id, revocationReason: "n/a" }; + + await revokeKYC(req, res, mockDataSource); + + expect(res.status).toHaveBeenCalledWith(401); + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe("field contract", () => { + it("emits exactly the audit fields, in the same shape, for every action", async () => { + stubUsers([ + { id: "user-5", stellarAddress: "GABCDEFGHIJKLMNOP", kycStatus: KYCStatus.APPROVED }, + REVIEWER, + ]); + + req.body = { userId: "user-5", reviewerId: REVIEWER.id, revocationReason: "reason" }; + await revokeKYC(req, res, mockDataSource); + const revokeKeys = Object.keys(logSpy.mock.calls[0][1]).sort(); + + logSpy.mockClear(); + stubUsers([ + { id: "user-5", stellarAddress: "GABCDEFGHIJKLMNOP", kycStatus: KYCStatus.PENDING }, + REVIEWER, + ]); + req.body = { userId: "user-5", reviewerId: REVIEWER.id, rejectionReason: "reason" }; + await rejectKYC(req, res, mockDataSource); + const rejectKeys = Object.keys(logSpy.mock.calls[0][1]).sort(); + + const expected = [ + "action", + "changed_at", + "new_status", + "previous_status", + "reason", + "reviewer_id", + "reviewer_wallet", + "wallet", + ]; + expect(revokeKeys).toEqual(expected); + expect(rejectKeys).toEqual(expected); + }); + + it("omits the reason field for approvals, which carry none", async () => { + stubUsers([ + { id: "user-6", stellarAddress: "GABCDEFGHIJKLMNOP", kycStatus: KYCStatus.PENDING }, + REVIEWER, + ]); + req.body = { userId: "user-6", reviewerId: REVIEWER.id }; + + await approveKYC(req, res, mockDataSource); + + expect(Object.keys(logSpy.mock.calls[0][1]).sort()).toEqual([ + "action", + "changed_at", + "new_status", + "previous_status", + "reviewer_id", + "reviewer_wallet", + "wallet", + ]); }); }); }); diff --git a/tests/validate-invoice-for-publish.test.ts b/tests/validate-invoice-for-publish.test.ts index 3b3ab4f..0a5f239 100644 --- a/tests/validate-invoice-for-publish.test.ts +++ b/tests/validate-invoice-for-publish.test.ts @@ -71,7 +71,7 @@ describe("validateInvoiceForPublish", () => { const invoice = createInvoice({ dueDate: futureDate(-1) }); const errors = validateInvoiceForPublish(invoice); expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ field: "dueDate", code: "DUE_DATE_TOO_SOON" }); + expect(errors[0]).toMatchObject({ field: "dueDate", code: "DUE_DATE_IN_PAST" }); }); it("returns a validation error for a due date less than 24 hours away", () => { @@ -97,7 +97,7 @@ describe("validateInvoiceForPublish", () => { const errors = validateInvoiceForPublish(invoice); expect(errors).toHaveLength(3); expect(errors.map((e) => e.code)).toEqual( - expect.arrayContaining(["FACE_VALUE_TOO_LOW", "DUE_DATE_TOO_SOON", "MISSING_DOCUMENT"]), + expect.arrayContaining(["FACE_VALUE_TOO_LOW", "DUE_DATE_IN_PAST", "MISSING_DOCUMENT"]), ); }); @@ -124,7 +124,7 @@ describe("validateInvoiceForPublish", () => { // Assert each error has the correct error code const errorCodes = errors.map((e) => e.code); expect(errorCodes).toContain("FACE_VALUE_TOO_LOW"); - expect(errorCodes).toContain("DUE_DATE_TOO_SOON"); + expect(errorCodes).toContain("DUE_DATE_IN_PAST"); expect(errorCodes).toContain("MISSING_DOCUMENT"); // Second call with the same invoice: should return the same errors (deterministic)