From 80f6ae41bf409a72c03ddea760f97cf45b99e2d5 Mon Sep 17 00:00:00 2001 From: Caleb Date: Sat, 29 Aug 2026 16:45:30 +0100 Subject: [PATCH] refactor(api): harden invoice auth and escrow services --- src/config/data-source.ts | 5 +- src/routes/auth.routes.ts | 36 +- src/services/invoice.service.ts | 329 +++++++++++++----- .../invoice-escrow-contract.service.ts | 255 ++++++++++---- tests/api-envelope.test.ts | 9 +- tests/auth.routes.test.ts | 41 ++- tests/e2e/full-flow.e2e.test.ts | 27 +- tests/invoice.service.test.ts | 57 ++- tests/unit/contract-guard.service.test.ts | 11 +- tests/unit/invoice-batch-publish.test.ts | 51 +-- .../invoice-escrow-contract.service.test.ts | 110 ++++-- 11 files changed, 684 insertions(+), 247 deletions(-) diff --git a/src/config/data-source.ts b/src/config/data-source.ts index 7ed0800..4a210e0 100644 --- a/src/config/data-source.ts +++ b/src/config/data-source.ts @@ -11,7 +11,6 @@ */ import { logger } from "../observability/logger"; import dataSource from "./database"; -import { logger } from "../observability/logger"; // Validate dataSource is properly initialized if (!dataSource) { @@ -30,6 +29,8 @@ export async function initializeDataSource(): Promise { } } catch (error) { logger.error("Failed to initialize DataSource", { error }); - throw new Error(`DataSource initialization failed: ${error instanceof Error ? error.message : String(error)}`); + throw new Error( + `DataSource initialization failed: ${error instanceof Error ? error.message : String(error)}` + ); } } diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index b949ebf..e95123f 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -1,4 +1,10 @@ -import { Router, type NextFunction, type Request, type RequestHandler, type Response } from "express"; +import { + Router, + type NextFunction, + type Request, + type RequestHandler, + type Response, +} from "express"; import Joi from "joi"; import { createAuthController } from "../controllers/auth.controller"; import { createAuthMiddleware } from "../middleware/auth.middleware"; @@ -10,7 +16,7 @@ import type { AppLogger } from "../observability/logger"; // Strict schemas: enforce Stellar G... format hint, length bounds, and sanitized inputs. const STELLAR_PUBLIC_KEY_PATTERN = /^G[A-Z2-7]{55}$/; const NONCE_PATTERN = /^[A-Za-z0-9:_-]+$/; -const SIGNATURE_PATTERN = /^[A-Za-z0-9+/=_:\-\.]+$/; +const SIGNATURE_PATTERN = /^[A-Za-z0-9+/=_:.-]+$/; type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise | void; @@ -43,7 +49,10 @@ const verifySchema = Joi.object({ .required(), signature: Joi.string() .trim() - .min(16) + // Encoding shape is validated here; cryptographic byte length remains an + // authentication concern in AuthService so malformed signatures continue + // to return 401 instead of changing the public contract to a 400. + .min(2) .max(512) .pattern(SIGNATURE_PATTERN) .messages({ @@ -57,7 +66,7 @@ const verifySchema = Joi.object({ function wrapAuthHandler( routeName: string, handler: AsyncRouteHandler, - logger: AppLogger, + logger: AppLogger ): RequestHandler { return async (req, res, next) => { try { @@ -95,30 +104,35 @@ export function createAuthRouter(authService: AuthService, logger: AppLogger): R const authMiddleware = createAuthMiddleware(authService); // `/challenge` and `/verify` are unauthenticated by design: the wallet // signature is the auth check, so they need their own abuse protection. - const authRateLimiter = createAuthRateLimitMiddleware(logger); + // Keep challenge generation and signature verification in independent + // buckets. Sharing one limiter allowed repeated challenge requests to + // consume the verification budget and lock a caller out of completing an + // otherwise valid login flow. + const challengeRateLimiter = createAuthRateLimitMiddleware(logger); + const verifyRateLimiter = createAuthRateLimitMiddleware(logger); router.use(markAuthRouteBase()); router.use(noStoreAuthResponse()); router.post( "/challenge", - authRateLimiter, + challengeRateLimiter, validateBody(challengeSchema), - wrapAuthHandler("auth.challenge", controller.challenge as AsyncRouteHandler, logger), + wrapAuthHandler("auth.challenge", controller.challenge as AsyncRouteHandler, logger) ); router.post( "/verify", - authRateLimiter, + verifyRateLimiter, validateBody(verifySchema), - wrapAuthHandler("auth.verify", controller.verify as AsyncRouteHandler, logger), + wrapAuthHandler("auth.verify", controller.verify as AsyncRouteHandler, logger) ); router.get( "/me", authMiddleware, - wrapAuthHandler("auth.me", controller.me as AsyncRouteHandler, logger), + wrapAuthHandler("auth.me", controller.me as AsyncRouteHandler, logger) ); return router; -} \ No newline at end of file +} diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index f01d238..aacb65d 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -1,4 +1,4 @@ -import { DataSource } from "typeorm"; +import { DataSource, In, type FindManyOptions } from "typeorm"; import Decimal from "decimal.js"; import { Invoice } from "../models/Invoice.model"; import { Investment } from "../models/Investment.model"; @@ -19,6 +19,8 @@ export interface InvoiceRepositoryContract { take?: number; order?: { [key: string]: "ASC" | "DESC" }; }): Promise; + /** Optional set-based lookup used by batch publishing to avoid N queries. */ + findManyByIds?(invoiceIds: string[], relations?: string[]): Promise; save(invoice: Invoice): Promise; count(options: { where: { sellerId: string; status?: InvoiceStatus } }): Promise; create(data: Partial): Invoice; @@ -36,7 +38,7 @@ export interface NotificationSink { userId: string, type: NotificationType, title: string, - message: string, + message: string ): Promise; } @@ -90,6 +92,11 @@ export interface PublishInvoiceInput { sellerId: string; } +export interface RejectInvoiceInput { + invoiceId: string; + rejectionReason: string; +} + export interface BatchPublishInvoicesInput { invoiceIds: string[]; sellerId: string; @@ -148,7 +155,11 @@ export interface GetInvoicesOptions { */ const VALID_TRANSITIONS: Record = { [InvoiceStatus.DRAFT]: [InvoiceStatus.PENDING, InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED], - [InvoiceStatus.PENDING]: [InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED, InvoiceStatus.REJECTED], + [InvoiceStatus.PENDING]: [ + InvoiceStatus.PUBLISHED, + InvoiceStatus.CANCELLED, + InvoiceStatus.REJECTED, + ], [InvoiceStatus.PUBLISHED]: [InvoiceStatus.FUNDED, InvoiceStatus.CANCELLED], [InvoiceStatus.FUNDED]: [InvoiceStatus.SETTLED, InvoiceStatus.CANCELLED], [InvoiceStatus.SETTLED]: [], @@ -185,7 +196,13 @@ export class InvoiceService { try { const amt = new Decimal(amount); const disc = new Decimal(discountRate); - if (!amt.isFinite() || !disc.isFinite() || amt.isNegative() || disc.isNegative() || disc.gt(100)) { + if ( + !amt.isFinite() || + !disc.isFinite() || + amt.isNegative() || + disc.isNegative() || + disc.gt(100) + ) { throw new ServiceError("invalid_amount", "Invalid amount or discount rate", 400); } const netAmount = amt.minus(amt.times(disc.dividedBy(100))); @@ -201,6 +218,19 @@ export class InvoiceService { return value.trim().slice(0, 64); } + private normalizePercentage(value: string, code: string, label: string): string { + try { + const percentage = new Decimal(value.trim()); + if (!percentage.isFinite() || percentage.isNegative() || percentage.gt(100)) { + throw new ServiceError(code, `${label} must be between 0 and 100`, 400); + } + return percentage.toFixed(2); + } catch (error) { + if (error instanceof ServiceError) throw error; + throw new ServiceError(code, `${label} must be between 0 and 100`, 400); + } + } + /** * Check if a status transition is valid */ @@ -333,53 +363,77 @@ export class InvoiceService { * Update an invoice (only draft invoices can be updated) */ async updateInvoice(input: UpdateInvoiceInput): Promise { - const invoice = await this.invoiceRepository.findOne({ - where: { id: input.invoiceId }, - }); - - if (!invoice) { - throw new ServiceError("invoice_not_found", "Invoice not found", 404); + const invoiceId = input.invoiceId?.trim(); + const sellerId = input.sellerId?.trim(); + if (!invoiceId || !sellerId) { + throw new ServiceError("invalid_input", "Invoice id and seller id are required", 400); } - // Verify ownership - if (invoice.sellerId !== input.sellerId) { - throw new ServiceError( - "unauthorized_invoice_access", - "You can only update your own invoices", - 403 - ); - } + try { + const invoice = await this.invoiceRepository.findOne({ + where: { id: invoiceId }, + }); - // Only draft invoices can be updated - if (invoice.status !== InvoiceStatus.DRAFT) { - throw new ServiceError( - "invalid_invoice_status", - `Cannot update invoice in ${invoice.status} status. Only draft invoices can be updated.`, - 400 - ); - } + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } - // Update fields - if (input.customerName) { - invoice.customerName = input.customerName; - } - if (input.amount) { - invoice.amount = input.amount; - invoice.discountRate = input.discountRate || invoice.discountRate; - invoice.netAmount = this.calculateNetAmount(invoice.amount, invoice.discountRate); - } else if (input.discountRate) { - invoice.discountRate = input.discountRate; - invoice.netAmount = this.calculateNetAmount(invoice.amount, invoice.discountRate); - } - if (input.dueDate) { - invoice.dueDate = input.dueDate; - } - if (input.riskScore) { - invoice.riskScore = input.riskScore; - } + // Verify ownership + if (invoice.sellerId !== sellerId) { + throw new ServiceError( + "unauthorized_invoice_access", + "You can only update your own invoices", + 403 + ); + } - const updated = await this.invoiceRepository.save(invoice); - return this.toDTO(updated); + // Only draft invoices can be updated + if (invoice.status !== InvoiceStatus.DRAFT) { + throw new ServiceError( + "invalid_invoice_status", + `Cannot update invoice in ${invoice.status} status. Only draft invoices can be updated.`, + 400 + ); + } + + // Update fields. Check against undefined so valid zero-valued decimal + // strings are not silently skipped. + if (input.customerName !== undefined) { + const customerName = input.customerName.trim().slice(0, 255); + if (!customerName) { + throw new ServiceError("invalid_customer_name", "Customer name is required", 400); + } + invoice.customerName = customerName; + } + if (input.amount !== undefined) { + invoice.amount = input.amount.trim(); + invoice.discountRate = input.discountRate?.trim() ?? invoice.discountRate; + invoice.netAmount = this.calculateNetAmount(invoice.amount, invoice.discountRate); + } else if (input.discountRate !== undefined) { + invoice.discountRate = input.discountRate.trim(); + invoice.netAmount = this.calculateNetAmount(invoice.amount, invoice.discountRate); + } + if (input.dueDate !== undefined) { + if (Number.isNaN(input.dueDate.getTime())) { + throw new ServiceError("invalid_due_date", "Due date must be valid", 400); + } + invoice.dueDate = input.dueDate; + } + if (input.riskScore !== undefined) { + invoice.riskScore = this.normalizePercentage( + input.riskScore, + "invalid_risk_score", + "Risk score" + ); + } + + const updated = await this.invoiceRepository.save(invoice); + return this.toDTO(updated); + } catch (error) { + if (error instanceof ServiceError) throw error; + logger.error("Failed to update invoice", { error, invoiceId, sellerId }); + throw new ServiceError("invoice_update_failed", "Failed to update invoice", 500); + } } /** @@ -416,6 +470,81 @@ export class InvoiceService { await this.invoiceRepository.save(invoice); } + /** + * Reject a pending invoice and notify its seller. Persistence is the source + * of truth, so notification delivery is best-effort and cannot roll back an + * otherwise successful administrative decision. + */ + async rejectInvoice(input: RejectInvoiceInput): Promise { + const invoiceId = input.invoiceId?.trim(); + const rejectionReason = input.rejectionReason?.trim(); + + if (!invoiceId) { + throw new ServiceError("invalid_invoice_id", "Invoice id is required", 400); + } + if (!rejectionReason) { + throw new ServiceError("invalid_rejection_reason", "Rejection reason is required", 400); + } + if (rejectionReason.length > 2_000) { + throw new ServiceError( + "invalid_rejection_reason", + "Rejection reason must not exceed 2000 characters", + 400 + ); + } + + try { + const invoice = await this.invoiceRepository.findOne({ where: { id: invoiceId } }); + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } + if (invoice.status === InvoiceStatus.REJECTED) { + throw new ServiceError("invoice_already_rejected", "Invoice is already rejected", 409); + } + if (!this.isValidTransition(invoice.status, InvoiceStatus.REJECTED)) { + throw new ServiceError( + "invalid_status_transition", + `Cannot transition from ${invoice.status} to ${InvoiceStatus.REJECTED}`, + 409 + ); + } + + const previousStatus = invoice.status; + invoice.status = InvoiceStatus.REJECTED; + invoice.rejectionReason = rejectionReason; + const updated = await this.invoiceRepository.save(invoice); + + logger.info("Invoice rejected by administrator", { + invoiceId: updated.id, + fromState: previousStatus, + toState: InvoiceStatus.REJECTED, + }); + + if (this.notificationSink) { + try { + await this.notificationSink.createNotification( + updated.sellerId, + NotificationType.INVOICE, + "Invoice rejected", + `Your invoice ${updated.invoiceNumber} was rejected: ${rejectionReason}` + ); + } catch (error) { + logger.warn("Failed to notify seller about invoice rejection", { + error, + invoiceId: updated.id, + sellerId: updated.sellerId, + }); + } + } + + return this.toDTO(updated); + } catch (error) { + if (error instanceof ServiceError) throw error; + logger.error("Failed to reject invoice", { error, invoiceId }); + throw new ServiceError("invoice_rejection_failed", "Failed to reject invoice", 500); + } + } + /** * Publish an invoice (transition from DRAFT to PUBLISHED) */ @@ -462,7 +591,7 @@ export class InvoiceService { throw new ServiceError( "invoice_not_publishable", `Invoice failed pre-publish validation: ${validationErrors.map((e) => e.message).join(" ")}`, - 400, + 400 ); } @@ -495,21 +624,34 @@ export class InvoiceService { * on each retry. */ async publishInvoicesBatch( - input: BatchPublishInvoicesInput, + input: BatchPublishInvoicesInput ): Promise { - const { invoiceIds, sellerId } = input; + const sellerId = input.sellerId?.trim(); + const invoiceIds = input.invoiceIds.map((invoiceId) => invoiceId.trim()); + + if (!sellerId) { + throw new ServiceError("invalid_seller_id", "Seller id is required", 400); + } - if (invoiceIds.length === 0) { + if (invoiceIds.length === 0 || invoiceIds.some((invoiceId) => !invoiceId)) { throw new ServiceError("empty_batch", "At least one invoice id is required", 400); } const uniqueIds = [...new Set(invoiceIds)]; + if (uniqueIds.length > 100) { + throw new ServiceError( + "batch_too_large", + "At most 100 invoices can be published at once", + 400 + ); + } + if (!this.dataSource) { throw new ServiceError( "batch_publish_unavailable", "Batch publishing requires a database connection", - 503, + 503 ); } @@ -519,21 +661,33 @@ export class InvoiceService { const publishable: Array<{ invoice: Invoice; sellerWallet: string }> = []; const rejections: BatchPublishRejection[] = []; - // Parallel fetch: avoids N sequential round-trips under heavy load (was ~N*~50ms) + // Fetch all requested invoices in one query. The previous Promise.all + // implementation still issued N concurrent database queries, which could + // exhaust the connection pool for a maximum-size batch. let fetched: Array<{ invoiceId: string; invoice: Invoice | null }>; try { - fetched = await Promise.all( - uniqueIds.map(async (invoiceId) => ({ - invoiceId, - invoice: await this.invoiceRepository.findOne({ - where: { id: invoiceId }, - relations: ["seller"], - }), - })), - ); + const invoices = this.invoiceRepository.findManyByIds + ? await this.invoiceRepository.findManyByIds(uniqueIds, ["seller"]) + : await Promise.all( + uniqueIds.map((invoiceId) => + this.invoiceRepository.findOne({ + where: { id: invoiceId }, + relations: ["seller"], + }) + ) + ).then((results) => results.filter((invoice): invoice is Invoice => invoice !== null)); + const invoicesById = new Map(invoices.map((invoice) => [invoice.id, invoice])); + fetched = uniqueIds.map((invoiceId) => ({ + invoiceId, + invoice: invoicesById.get(invoiceId) ?? null, + })); } catch (error) { logger.error("Failed to fetch batch invoices", { error, sellerId }); - throw new ServiceError("batch_fetch_failed", "Failed to fetch invoices for batch publish", 500); + throw new ServiceError( + "batch_fetch_failed", + "Failed to fetch invoices for batch publish", + 500 + ); } for (const { invoiceId, invoice } of fetched) { @@ -560,7 +714,7 @@ export class InvoiceService { throw new ServiceError( "kyc_approval_required", "KYC approval is required to publish invoices", - 403, + 403 ); } @@ -591,21 +745,30 @@ export class InvoiceService { "batch_publish_rejected", `${rejections.length} of ${uniqueIds.length} invoices cannot be published; no invoices were changed`, 400, - { rejections }, + { 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; - }); + let saved: Invoice[]; + try { + saved = await this.dataSource.transaction(async (manager) => { + const invoices = publishable.map(({ invoice }) => { + invoice.status = InvoiceStatus.PUBLISHED; + return invoice; + }); + return manager.save(invoices); + }); + } catch (error) { + logger.error("Failed to publish invoice batch", { + error, + sellerId, + invoiceCount: uniqueIds.length, + }); + throw new ServiceError("batch_publish_failed", "Failed to publish invoice batch", 500); + } publishable.forEach(({ sellerWallet }, index) => { logInvoiceTransition(logger, { @@ -710,11 +873,7 @@ export class InvoiceService { } if (!this.dataSource) { - throw new ServiceError( - "internal_error", - "Database connection unavailable", - 500 - ); + throw new ServiceError("internal_error", "Database connection unavailable", 500); } const investmentRepository = this.dataSource.getRepository(Investment); @@ -817,8 +976,22 @@ export function createInvoiceService( ): InvoiceService { const invoiceRepository = dataSource.getRepository(Invoice); + const repositoryContract: InvoiceRepositoryContract = { + findOne: (options) => invoiceRepository.findOne(options), + findOneBy: (options) => invoiceRepository.findOneBy(options), + find: (options) => invoiceRepository.find(options as FindManyOptions), + findManyByIds: (invoiceIds, relations) => + invoiceRepository.find({ + where: { id: In(invoiceIds) }, + relations, + }), + save: (invoice) => invoiceRepository.save(invoice), + count: (options) => invoiceRepository.count(options as FindManyOptions), + create: (data) => invoiceRepository.create(data), + }; + return new InvoiceService({ - invoiceRepository, + invoiceRepository: repositoryContract, ipfsService, dataSource, notificationSink, diff --git a/src/services/stellar/invoice-escrow-contract.service.ts b/src/services/stellar/invoice-escrow-contract.service.ts index 05bb8de..1790010 100644 --- a/src/services/stellar/invoice-escrow-contract.service.ts +++ b/src/services/stellar/invoice-escrow-contract.service.ts @@ -20,12 +20,21 @@ import type { } from "../../types/soroban.types"; export type CreateEscrowInput = CreateEscrowParams; -export type { - CreateEscrowResult, - FundEscrowParams, - RecordPaymentParams, - SettleEscrowParams, -}; +export type { CreateEscrowResult, FundEscrowParams, RecordPaymentParams, SettleEscrowParams }; + +const DEFAULT_RPC_TIMEOUT_MS = 15_000; +const MAX_I128 = (1n << 127n) - 1n; + +export class InvoiceEscrowContractError extends Error { + constructor( + readonly code: string, + message: string, + readonly cause?: unknown + ) { + super(message); + this.name = "InvoiceEscrowContractError"; + } +} export interface InvoiceEscrowContractServiceDependencies { contractId: string; @@ -34,6 +43,7 @@ export interface InvoiceEscrowContractServiceDependencies { platformSecretKey?: string; server?: SorobanRpc.Server; logger?: AppLogger; + rpcTimeoutMs?: number; } export class InvoiceEscrowContractService { @@ -43,10 +53,11 @@ export class InvoiceEscrowContractService { private readonly networkPassphrase?: string; private readonly platformSecretKey?: string; private readonly logger: AppLogger; + private readonly rpcTimeoutMs: number; constructor( dependenciesOrContractId: string | InvoiceEscrowContractServiceDependencies, - logger?: AppLogger, + logger?: AppLogger ) { if (typeof dependenciesOrContractId === "string") { if (!dependenciesOrContractId) { @@ -55,6 +66,7 @@ export class InvoiceEscrowContractService { this.contractId = dependenciesOrContractId; this.contract = new Contract(dependenciesOrContractId); this.logger = logger ?? globalLogger; + this.rpcTimeoutMs = DEFAULT_RPC_TIMEOUT_MS; } else { if (!dependenciesOrContractId.contractId) { throw new Error("contractId is required."); @@ -71,6 +83,111 @@ export class InvoiceEscrowContractService { }); } this.logger = dependenciesOrContractId.logger ?? logger ?? globalLogger; + const rpcTimeoutMs = dependenciesOrContractId.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; + if (!Number.isSafeInteger(rpcTimeoutMs) || rpcTimeoutMs <= 0) { + throw new Error("rpcTimeoutMs must be a positive integer."); + } + this.rpcTimeoutMs = rpcTimeoutMs; + } + } + + private normalizeInvoiceId(invoiceId: string): string { + const normalized = invoiceId?.trim(); + if (!normalized) { + throw new InvoiceEscrowContractError("invalid_invoice_id", "invoiceId is required."); + } + if (normalized.length > 64) { + throw new InvoiceEscrowContractError( + "invalid_invoice_id", + "invoiceId must not exceed 64 characters." + ); + } + return normalized; + } + + private normalizeAmount(amount: bigint | number | string): bigint { + let normalized: bigint; + try { + if (typeof amount === "number" && (!Number.isSafeInteger(amount) || amount <= 0)) { + throw new Error("unsafe numeric amount"); + } + if (typeof amount === "string" && !/^\d+$/.test(amount.trim())) { + throw new Error("invalid amount string"); + } + normalized = typeof amount === "bigint" ? amount : BigInt(amount); + } catch (error) { + throw new InvoiceEscrowContractError( + "invalid_amount", + "amountStroops must be a positive integer.", + error + ); + } + + if (normalized <= 0n || normalized > MAX_I128) { + throw new InvoiceEscrowContractError( + "invalid_amount", + "amountStroops must be a positive i128 integer." + ); + } + return normalized; + } + + private normalizeDueDate(dueDateTimestamp: number): number { + if (!Number.isSafeInteger(dueDateTimestamp) || dueDateTimestamp <= 0) { + throw new InvoiceEscrowContractError( + "invalid_due_date", + "dueDateTimestamp must be a positive integer." + ); + } + return dueDateTimestamp; + } + + private toAddressScVal(value: string, field: string): xdr.ScVal { + const normalized = value?.trim(); + if (!normalized) { + throw new InvoiceEscrowContractError("invalid_address", `${field} is required.`); + } + try { + return new Address(normalized).toScVal(); + } catch (error) { + throw new InvoiceEscrowContractError( + "invalid_address", + `${field} must be a valid Stellar address.`, + error + ); + } + } + + private async executeRpc(operation: string, work: () => Promise): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + work(), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject( + new InvoiceEscrowContractError( + "rpc_timeout", + `Soroban RPC ${operation} timed out after ${this.rpcTimeoutMs}ms.` + ) + ); + }, this.rpcTimeoutMs); + }), + ]); + } catch (error) { + this.logger.error("Soroban RPC request failed.", { + operation, + sorobanContractId: this.contractId, + error: error instanceof Error ? error.message : "Unknown error", + }); + if (error instanceof InvoiceEscrowContractError) throw error; + throw new InvoiceEscrowContractError( + "rpc_request_failed", + `Soroban RPC ${operation} failed.`, + error + ); + } finally { + if (timeout) clearTimeout(timeout); } } @@ -82,18 +199,19 @@ export class InvoiceEscrowContractService { sellerAddress: string, amountStroops: bigint | number | string, dueDateTimestamp: number, - paymentTokenAddress: string, + paymentTokenAddress: string ): xdr.Operation { - const amountBigInt = - typeof amountStroops === "bigint" ? amountStroops : BigInt(amountStroops); + const normalizedInvoiceId = this.normalizeInvoiceId(invoiceId); + const amountBigInt = this.normalizeAmount(amountStroops); + const dueDate = this.normalizeDueDate(dueDateTimestamp); return this.contract.call( "create_escrow", - nativeToScVal(invoiceId, { type: "symbol" }), - new Address(sellerAddress).toScVal(), + nativeToScVal(normalizedInvoiceId, { type: "symbol" }), + this.toAddressScVal(sellerAddress, "sellerAddress"), nativeToScVal(amountBigInt, { type: "i128" }), - nativeToScVal(dueDateTimestamp, { type: "u64" }), - new Address(paymentTokenAddress).toScVal(), + nativeToScVal(dueDate, { type: "u64" }), + this.toAddressScVal(paymentTokenAddress, "paymentTokenAddress") ); } @@ -103,16 +221,16 @@ export class InvoiceEscrowContractService { public buildFundEscrowTx( invoiceId: string, investorAddress: string, - amountStroops: bigint | number | string, + amountStroops: bigint | number | string ): xdr.Operation { - const amountBigInt = - typeof amountStroops === "bigint" ? amountStroops : BigInt(amountStroops); + const normalizedInvoiceId = this.normalizeInvoiceId(invoiceId); + const amountBigInt = this.normalizeAmount(amountStroops); return this.contract.call( "fund_escrow", - nativeToScVal(invoiceId, { type: "symbol" }), - new Address(investorAddress).toScVal(), - nativeToScVal(amountBigInt, { type: "i128" }), + nativeToScVal(normalizedInvoiceId, { type: "symbol" }), + this.toAddressScVal(investorAddress, "investorAddress"), + nativeToScVal(amountBigInt, { type: "i128" }) ); } @@ -122,16 +240,16 @@ export class InvoiceEscrowContractService { public buildRecordPaymentTx( invoiceId: string, payerAddress: string, - amountStroops: bigint | number | string, + amountStroops: bigint | number | string ): xdr.Operation { - const amountBigInt = - typeof amountStroops === "bigint" ? amountStroops : BigInt(amountStroops); + const normalizedInvoiceId = this.normalizeInvoiceId(invoiceId); + const amountBigInt = this.normalizeAmount(amountStroops); return this.contract.call( "record_payment", - nativeToScVal(invoiceId, { type: "symbol" }), - new Address(payerAddress).toScVal(), - nativeToScVal(amountBigInt, { type: "i128" }), + nativeToScVal(normalizedInvoiceId, { type: "symbol" }), + this.toAddressScVal(payerAddress, "payerAddress"), + nativeToScVal(amountBigInt, { type: "i128" }) ); } @@ -141,7 +259,7 @@ export class InvoiceEscrowContractService { public buildSettleEscrowTx(invoiceId: string): xdr.Operation { return this.contract.call( "settle_escrow", - nativeToScVal(invoiceId, { type: "symbol" }), + nativeToScVal(this.normalizeInvoiceId(invoiceId), { type: "symbol" }) ); } @@ -149,13 +267,15 @@ export class InvoiceEscrowContractService { * Simulates a transaction against the Soroban RPC endpoint to verify resource limits and auth footprint. */ public async simulateTransaction( - transaction: Transaction | FeeBumpTransaction, + transaction: Transaction | FeeBumpTransaction ): Promise { if (!this.rpcServer) { throw new Error("Soroban RPC server is not configured for simulation."); } - const simResponse = await this.rpcServer.simulateTransaction(transaction); + const simResponse = await this.executeRpc("simulation", () => + this.rpcServer!.simulateTransaction(transaction) + ); const successResponse = simResponse as unknown as { minResourceFee?: string; cost?: { cpuInsns?: string; memBytes?: string }; @@ -183,13 +303,15 @@ export class InvoiceEscrowContractService { * Submits a transaction to the Stellar network via Soroban RPC sendTransaction. */ public async submitTransaction( - transaction: Transaction | FeeBumpTransaction, + transaction: Transaction | FeeBumpTransaction ): Promise { if (!this.rpcServer) { throw new Error("Soroban RPC server is not configured for submission."); } - const response = await this.rpcServer.sendTransaction(transaction); + const response = await this.executeRpc("submission", () => + this.rpcServer!.sendTransaction(transaction) + ); return { status: response.status, txHash: response.hash, @@ -202,38 +324,49 @@ export class InvoiceEscrowContractService { * Ensures that only sanitized metadata (invoiceId, sorobanContractId, sellerAddress, amountStroops) * is logged without leaking any secret keys, signing seeds, or auth tokens. */ - public async createEscrowOnChain( - input: CreateEscrowInput, - ): Promise { - const amountBigInt = - typeof input.amountStroops === "bigint" - ? input.amountStroops - : BigInt(input.amountStroops); - - const operation = this.buildCreateEscrowTx( - input.invoiceId, - input.sellerAddress, - amountBigInt, - input.dueDateTimestamp, - input.paymentTokenAddress, - ); + public async createEscrowOnChain(input: CreateEscrowInput): Promise { + const invoiceId = this.normalizeInvoiceId(input.invoiceId); + const sellerAddress = input.sellerAddress?.trim(); - const amountStroopsStr = amountBigInt.toString(); + try { + const amountBigInt = this.normalizeAmount(input.amountStroops); + const operation = this.buildCreateEscrowTx( + invoiceId, + sellerAddress, + amountBigInt, + input.dueDateTimestamp, + input.paymentTokenAddress + ); - // Log structured event on successful escrow creation - this.logger.info("Soroban escrow created successfully on-chain.", { - invoiceId: input.invoiceId, - sorobanContractId: this.contractId, - sellerAddress: input.sellerAddress, - amountStroops: amountStroopsStr, - }); + const amountStroopsStr = amountBigInt.toString(); - return { - contractId: this.contractId, - invoiceId: input.invoiceId, - sellerAddress: input.sellerAddress, - amountStroops: amountStroopsStr, - operation, - }; + // Log structured event on successful escrow creation + this.logger.info("Soroban escrow created successfully on-chain.", { + invoiceId, + sorobanContractId: this.contractId, + sellerAddress, + amountStroops: amountStroopsStr, + }); + + return { + contractId: this.contractId, + invoiceId, + sellerAddress, + amountStroops: amountStroopsStr, + operation, + }; + } catch (error) { + this.logger.error("Failed to create Soroban escrow operation.", { + invoiceId, + sorobanContractId: this.contractId, + error: error instanceof Error ? error.message : "Unknown error", + }); + if (error instanceof InvoiceEscrowContractError) throw error; + throw new InvoiceEscrowContractError( + "create_escrow_failed", + "Failed to create Soroban escrow operation.", + error + ); + } } } diff --git a/tests/api-envelope.test.ts b/tests/api-envelope.test.ts index 0c03b9f..60107bb 100644 --- a/tests/api-envelope.test.ts +++ b/tests/api-envelope.test.ts @@ -76,16 +76,13 @@ describe("Response envelope", () => { }, }); - // Add a route that throws an error before the error middleware is applied - // We use the router pattern to ensure the route is matched before notFoundMiddleware - const router = request.agent(app); - // Simulate an internal error by accessing a route that will throw // Since we can't add routes after app creation, we'll test via the auth routes - // which will throw an error from the stub service + // which will throw an error from the stub service. The public key must pass + // route validation so the request reaches that service. const response = await request(app) .post("/api/v1/auth/challenge") - .send({ publicKey: "test" }) + .send({ publicKey: "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI" }) .expect(500); expect(response.body).toMatchObject({ diff --git a/tests/auth.routes.test.ts b/tests/auth.routes.test.ts index d1ff9b4..87aa94b 100644 --- a/tests/auth.routes.test.ts +++ b/tests/auth.routes.test.ts @@ -31,10 +31,7 @@ class InMemoryUserRepository implements UserRepositoryContract { } async findByStellarAddress(stellarAddress: string) { - return ( - [...this.users.values()].find((user) => user.stellarAddress === stellarAddress) ?? - null - ); + return [...this.users.values()].find((user) => user.stellarAddress === stellarAddress) ?? null; } async save(user: Partial) { @@ -83,8 +80,7 @@ class InMemoryChallengeRepository implements ChallengeRepositoryContract { return ( [...this.challenges.values()].find( (challenge) => - challenge.stellarAddress === stellarAddress && - challenge.nonceHash === nonceHash, + challenge.stellarAddress === stellarAddress && challenge.nonceHash === nonceHash ) ?? null ); } @@ -129,7 +125,7 @@ function createTestServer(challengeTtlMs = 60_000) { } afterEach(() => { -jest.useRealTimers(); + jest.useRealTimers(); }); describe("Auth routes", () => { @@ -281,8 +277,8 @@ describe("Auth routes", () => { expect( [...challengeRepository.challenges.values()].some( - (challenge) => challenge.consumedAt !== null, - ), + (challenge) => challenge.consumedAt !== null + ) ).toBe(true); }); @@ -330,6 +326,31 @@ describe("Auth routes", () => { .expect(429); }); + it("keeps verification available when the challenge rate-limit bucket is full", async () => { + const { app } = createTestServer(); + const keypair = Keypair.random(); + + const challengeResponse = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: keypair.publicKey() }) + .expect(201); + + for (let i = 0; i < 9; i += 1) { + await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: keypair.publicKey() }) + .expect(201); + } + + const { nonce, message } = challengeResponse.body.challenge; + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + await request(app) + .post("/api/v1/auth/verify") + .send({ publicKey: keypair.publicKey(), nonce, signature }) + .expect(200); + }); + it("returns 401 from /me when the bearer token is missing", async () => { const { app } = createTestServer(); @@ -342,4 +363,4 @@ describe("Auth routes", () => { }, }); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/full-flow.e2e.test.ts b/tests/e2e/full-flow.e2e.test.ts index 7ffee60..5816368 100644 --- a/tests/e2e/full-flow.e2e.test.ts +++ b/tests/e2e/full-flow.e2e.test.ts @@ -148,6 +148,7 @@ describe("E2E: Complete Invoice Financing Flow", () => { enabled: false, contractId: null, fundingMode: "wallet_xdr", + rpcUrl: null, }, ipfs: { apiUrl: "https://api.pinata.cloud", @@ -175,7 +176,15 @@ describe("E2E: Complete Invoice Financing Flow", () => { database: ":memory:", synchronize: true, logging: false, - entities: [User, Invoice, Investment, AuthChallenge, Transaction, KYCVerification, Notification], + entities: [ + User, + Invoice, + Investment, + AuthChallenge, + Transaction, + KYCVerification, + Notification, + ], }); await dataSource.initialize(); @@ -227,9 +236,7 @@ describe("E2E: Complete Invoice Financing Flow", () => { const { nonce, message } = challengeRes.body.challenge; // Sign the challenge message - const signature = sellerKeypair - .sign(Buffer.from(message, "utf8")) - .toString("hex"); + const signature = sellerKeypair.sign(Buffer.from(message, "utf8")).toString("hex"); // Verify challenge and get token const verifyRes = await request(app) @@ -258,9 +265,7 @@ describe("E2E: Complete Invoice Financing Flow", () => { const { nonce, message } = challengeRes.body.challenge; - const signature = investorKeypair - .sign(Buffer.from(message, "utf8")) - .toString("hex"); + const signature = investorKeypair.sign(Buffer.from(message, "utf8")).toString("hex"); const verifyRes = await request(app) .post("/api/v1/auth/verify") @@ -367,9 +372,7 @@ describe("E2E: Complete Invoice Financing Flow", () => { expect(invoice).toBeDefined(); expect(invoice?.status).toBe(InvoiceStatus.PUBLISHED); expect(invoice?.sellerId).toBe(sellerId); - expect(invoice?.ipfsHash).toBe( - "QmMockHash1234567890123456789012345678901234567890" - ); + expect(invoice?.ipfsHash).toBe("QmMockHash1234567890123456789012345678901234567890"); }); }); @@ -387,9 +390,7 @@ describe("E2E: Complete Invoice Financing Flow", () => { expect(Array.isArray(marketplaceRes.body.data)).toBe(true); expect(marketplaceRes.body.data.length).toBeGreaterThan(0); - const listedInvoice = marketplaceRes.body.data.find( - (inv: any) => inv.id === invoiceId - ); + const listedInvoice = marketplaceRes.body.data.find((inv: any) => inv.id === invoiceId); expect(listedInvoice).toBeDefined(); expect(listedInvoice.invoiceNumber).toBe("INV-E2E-001"); expect(toNum(listedInvoice.amount)).toBeCloseTo(10000, 2); diff --git a/tests/invoice.service.test.ts b/tests/invoice.service.test.ts index 0c5c8cd..8408b6d 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -121,7 +121,7 @@ describe("InvoiceService", () => { }); expect(mockInvoiceRepository.create).toHaveBeenCalledWith( - expect.objectContaining({ netAmount: "29.8401" }), + expect.objectContaining({ netAmount: "29.8401" }) ); expect(result.netAmount).toBe("29.8401"); }); @@ -329,6 +329,39 @@ describe("InvoiceService", () => { statusCode: 404, }); }); + + it("should sanitize mutable text fields and normalize risk score", async () => { + mockInvoiceRepository.findOne.mockResolvedValue({ ...mockInvoice }); + mockInvoiceRepository.save.mockImplementation(async (invoice: Invoice) => invoice); + + const result = await invoiceService.updateInvoice({ + sellerId: " seller-456 ", + invoiceId: " invoice-123 ", + customerName: " Updated Customer ", + riskScore: "7.5", + }); + + expect(result.customerName).toBe("Updated Customer"); + expect(result.riskScore).toBe("7.50"); + expect(mockInvoiceRepository.findOne).toHaveBeenCalledWith({ + where: { id: "invoice-123" }, + }); + }); + + it("should isolate unexpected repository failures", async () => { + mockInvoiceRepository.findOne.mockRejectedValue(new Error("database unavailable")); + + await expect( + invoiceService.updateInvoice({ + sellerId: "seller-456", + invoiceId: "invoice-123", + customerName: "Updated", + }) + ).rejects.toMatchObject({ + code: "invoice_update_failed", + statusCode: 500, + }); + }); }); // ============ DELETE INVOICE TESTS ============ @@ -379,7 +412,10 @@ describe("InvoiceService", () => { ...mockInvoice, dueDate: new Date(Date.now() + 48 * 60 * 60 * 1000), ipfsHash: "QmTestHash", - seller: { kycStatus: "approved", stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV" }, + seller: { + kycStatus: "approved", + stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV", + }, } as Invoice; it("should transition draft invoice to published", async () => { @@ -399,7 +435,10 @@ describe("InvoiceService", () => { const soonDueInvoice = { ...mockInvoice, dueDate: new Date(Date.now() + 60 * 60 * 1000), // 1 hour in future - seller: { kycStatus: "approved", stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV" }, + seller: { + kycStatus: "approved", + stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV", + }, }; mockInvoiceRepository.findOne.mockResolvedValue(soonDueInvoice); @@ -419,7 +458,10 @@ describe("InvoiceService", () => { ...mockInvoice, status: InvoiceStatus.SETTLED, dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - seller: { kycStatus: "approved", stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV" }, + seller: { + kycStatus: "approved", + stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV", + }, }; mockInvoiceRepository.findOne.mockResolvedValue(settledInvoice); @@ -466,7 +508,10 @@ describe("InvoiceService", () => { const invoiceWithPendingKYC = { ...publishableInvoice, status: InvoiceStatus.DRAFT, - seller: { kycStatus: "pending", stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV" }, + seller: { + kycStatus: "pending", + stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV", + }, }; mockInvoiceRepository.findOne.mockResolvedValue(invoiceWithPendingKYC); @@ -489,7 +534,7 @@ describe("InvoiceService", () => { invoiceService.publishInvoice({ invoiceId: "invoice-123", sellerId: "seller-456", - }), + }) ).rejects.toMatchObject({ code: "invoice_not_publishable", statusCode: 400, diff --git a/tests/unit/contract-guard.service.test.ts b/tests/unit/contract-guard.service.test.ts index 31179c2..8aa8867 100644 --- a/tests/unit/contract-guard.service.test.ts +++ b/tests/unit/contract-guard.service.test.ts @@ -5,7 +5,8 @@ import { } from "@/services/stellar/contract-guard.service"; const RPC_URL = "https://soroban-testnet.example/rpc"; -const CONTRACT_ID = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; +const CONTRACT_ID = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; +const OTHER_CONTRACT_ID = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; /** * Stand-in for a base64 ledger entry. The real decoder is XDR; these tests @@ -74,8 +75,9 @@ describe("ContractGuardService", () => { }); it("produces a different key for a different contract", () => { - const other = "CBQHNAXSI55GX2GN6D67GK7BHVPSLJUGZQEU7WJ5LKR5PNUCGLIMAO4K"; - expect(buildPausedLedgerKey(CONTRACT_ID)).not.toEqual(buildPausedLedgerKey(other)); + expect(buildPausedLedgerKey(CONTRACT_ID)).not.toEqual( + buildPausedLedgerKey(OTHER_CONTRACT_ID) + ); }); }); @@ -146,12 +148,11 @@ describe("ContractGuardService", () => { }); 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); + await service.checkContractPauseState(OTHER_CONTRACT_ID); expect(fetchFn).toHaveBeenCalledTimes(2); }); diff --git a/tests/unit/invoice-batch-publish.test.ts b/tests/unit/invoice-batch-publish.test.ts index 350ebcc..1ea02b3 100644 --- a/tests/unit/invoice-batch-publish.test.ts +++ b/tests/unit/invoice-batch-publish.test.ts @@ -45,21 +45,20 @@ describe("InvoiceService.publishInvoicesBatch", () => { let transactionCommitted: boolean; let service: InvoiceService; - /** Stub the repository so `findOne` resolves each invoice by id. */ + /** Stub the repository's set-based batch lookup. */ function stubInvoices(invoices: Invoice[]) { - repository.findOne.mockImplementation(async ({ where }: { where: { id: string } }) => { - return invoices.find((invoice) => invoice.id === where.id) ?? null; - }); + repository.findManyByIds.mockResolvedValue(invoices); } beforeEach(() => { transactionCommitted = false; - managerSave = jest.fn(async (invoice: Invoice) => invoice); + managerSave = jest.fn(async (invoices: Invoice[]) => invoices); repository = { findOne: jest.fn(), findOneBy: jest.fn(), find: jest.fn(), + findManyByIds: jest.fn(), save: jest.fn(), count: jest.fn(), create: jest.fn(), @@ -94,7 +93,10 @@ describe("InvoiceService.publishInvoicesBatch", () => { 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); + expect(repository.findManyByIds).toHaveBeenCalledTimes(1); + expect(repository.findManyByIds).toHaveBeenCalledWith(["a", "b", "c"], ["seller"]); + expect(managerSave).toHaveBeenCalledTimes(1); + expect(managerSave).toHaveBeenCalledWith(invoices); for (const invoice of invoices) { expect(invoice.status).toBe(InvoiceStatus.PUBLISHED); } @@ -110,13 +112,14 @@ describe("InvoiceService.publishInvoicesBatch", () => { expect(result.count).toBe(1); expect(managerSave).toHaveBeenCalledTimes(1); + expect(managerSave).toHaveBeenCalledWith([expect.objectContaining({ id: "a" })]); }); it("publishes a single-invoice batch", async () => { stubInvoices([draftInvoice("a")]); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }) ).resolves.toMatchObject({ count: 1 }); }); }); @@ -134,7 +137,7 @@ describe("InvoiceService.publishInvoicesBatch", () => { ]); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a", "b", "c"], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: ["a", "b", "c"], sellerId: SELLER_ID }) ).rejects.toBeInstanceOf(ServiceError); expect(dataSource.transaction).not.toHaveBeenCalled(); @@ -146,7 +149,7 @@ describe("InvoiceService.publishInvoicesBatch", () => { stubInvoices([good, draftInvoice("b", { ipfsHash: null })]); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }) ).rejects.toBeInstanceOf(ServiceError); expect(good.status).toBe(InvoiceStatus.DRAFT); @@ -154,15 +157,11 @@ describe("InvoiceService.publishInvoicesBatch", () => { 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"); - }); + managerSave.mockRejectedValueOnce(new Error("deadlock detected")); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }), - ).rejects.toThrow("deadlock detected"); + service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }) + ).rejects.toMatchObject({ code: "batch_publish_failed", statusCode: 500 }); expect(transactionCommitted).toBe(false); }); @@ -185,8 +184,9 @@ describe("InvoiceService.publishInvoicesBatch", () => { expect(error.code).toBe("batch_publish_rejected"); expect(error.statusCode).toBe(400); - const rejections = (error.details as { rejections: Array<{ invoiceId: string; code: string }> }) - .rejections; + 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"); @@ -200,8 +200,9 @@ describe("InvoiceService.publishInvoicesBatch", () => { .publishInvoicesBatch({ invoiceIds: ["a", "missing"], sellerId: SELLER_ID }) .catch((e) => e)) as ServiceError; - const rejections = (error.details as { rejections: Array<{ invoiceId: string; code: string }> }) - .rejections; + 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" }, ]); @@ -239,17 +240,19 @@ describe("InvoiceService.publishInvoicesBatch", () => { describe("preconditions", () => { it("rejects an empty batch", async () => { await expect( - service.publishInvoicesBatch({ invoiceIds: [], sellerId: SELLER_ID }), + 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 }), + draftInvoice("a", { + seller: { ...approvedSeller(), kycStatus: KYCStatus.PENDING } as never, + }), ]); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }) ).rejects.toMatchObject({ code: "kyc_approval_required", statusCode: 403 }); expect(dataSource.transaction).not.toHaveBeenCalled(); @@ -263,7 +266,7 @@ describe("InvoiceService.publishInvoicesBatch", () => { stubInvoices([draftInvoice("a")]); await expect( - noDbService.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }), + noDbService.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }) ).rejects.toMatchObject({ code: "batch_publish_unavailable", statusCode: 503 }); }); }); diff --git a/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts b/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts index 019720d..7195436 100644 --- a/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts +++ b/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts @@ -1,5 +1,8 @@ import { Address, scValToNative } from "stellar-sdk"; -import { InvoiceEscrowContractService } from "../../../../src/services/stellar/invoice-escrow-contract.service"; +import { + InvoiceEscrowContractError, + InvoiceEscrowContractService, +} from "../../../../src/services/stellar/invoice-escrow-contract.service"; import type { AppLogger } from "../../../../src/observability/logger"; describe("InvoiceEscrowContractService", () => { @@ -34,20 +37,25 @@ describe("InvoiceEscrowContractService", () => { }); it("should initialize correctly with string contract ID", () => { - const stringInitService = new InvoiceEscrowContractService( - ESCROW_CONTRACT_ID, - mockLogger, - ); + const stringInitService = new InvoiceEscrowContractService(ESCROW_CONTRACT_ID, mockLogger); expect(stringInitService.contractId).toBe(ESCROW_CONTRACT_ID); }); it("should throw error if contractId is empty", () => { - expect(() => new InvoiceEscrowContractService("")).toThrow( - "contractId is required.", + expect(() => new InvoiceEscrowContractService("")).toThrow("contractId is required."); + expect(() => new InvoiceEscrowContractService({ contractId: "" })).toThrow( + "contractId is required." ); + }); + + it("should reject an invalid RPC timeout", () => { expect( - () => new InvoiceEscrowContractService({ contractId: "" }), - ).toThrow("contractId is required."); + () => + new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + rpcTimeoutMs: 0, + }) + ).toThrow("rpcTimeoutMs must be a positive integer."); }); }); @@ -58,15 +66,11 @@ describe("InvoiceEscrowContractService", () => { TEST_SELLER, TEST_AMOUNT_STROOPS, TEST_DUE_DATE, - TEST_TOKEN, + TEST_TOKEN ); expect(op.body().switch().name).toBe("invokeHostFunction"); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); expect(invokeContractArgs.functionName().toString()).toBe("create_escrow"); @@ -114,7 +118,7 @@ describe("InvoiceEscrowContractService", () => { sorobanContractId: ESCROW_CONTRACT_ID, sellerAddress: TEST_SELLER, amountStroops: TEST_AMOUNT_STROOPS.toString(), - }, + } ); }); @@ -149,11 +153,7 @@ describe("InvoiceEscrowContractService", () => { it("should construct valid fund_escrow host function invocation", () => { const op = service.buildFundEscrowTx(TEST_INVOICE_ID, TEST_SELLER, TEST_AMOUNT_STROOPS); expect(op.body().switch().name).toBe("invokeHostFunction"); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); expect(invokeContractArgs.functionName().toString()).toBe("fund_escrow"); const args = invokeContractArgs.args(); @@ -162,16 +162,21 @@ describe("InvoiceEscrowContractService", () => { expect(Address.fromScVal(args[1]).toString()).toBe(TEST_SELLER); expect(BigInt(scValToNative(args[2]))).toBe(TEST_AMOUNT_STROOPS); }); + + it("should reject non-positive and unsafe amounts", () => { + expect(() => service.buildFundEscrowTx(TEST_INVOICE_ID, TEST_SELLER, 0)).toThrow( + expect.objectContaining({ code: "invalid_amount" }) + ); + expect(() => + service.buildFundEscrowTx(TEST_INVOICE_ID, TEST_SELLER, Number.MAX_SAFE_INTEGER + 1) + ).toThrow(expect.objectContaining({ code: "invalid_amount" })); + }); }); describe("buildRecordPaymentTx", () => { it("should construct valid record_payment host function invocation", () => { const op = service.buildRecordPaymentTx(TEST_INVOICE_ID, TEST_SELLER, TEST_AMOUNT_STROOPS); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); expect(invokeContractArgs.functionName().toString()).toBe("record_payment"); const args = invokeContractArgs.args(); @@ -183,17 +188,19 @@ describe("InvoiceEscrowContractService", () => { describe("buildSettleEscrowTx", () => { it("should construct valid settle_escrow host function invocation", () => { const op = service.buildSettleEscrowTx(TEST_INVOICE_ID); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); expect(invokeContractArgs.functionName().toString()).toBe("settle_escrow"); const args = invokeContractArgs.args(); expect(args).toHaveLength(1); expect(scValToNative(args[0])).toBe(TEST_INVOICE_ID); }); + + it("should reject empty invoice ids", () => { + expect(() => service.buildSettleEscrowTx(" ")).toThrow( + expect.objectContaining({ code: "invalid_invoice_id" }) + ); + }); }); describe("RPC simulation and submission", () => { @@ -233,5 +240,46 @@ describe("InvoiceEscrowContractService", () => { expect(res.status).toBe("PENDING"); expect(res.txHash).toBe("abc123hash"); }); + + it("should wrap and log RPC failures without exposing implementation errors", async () => { + const mockServer = { + simulateTransaction: jest.fn().mockRejectedValue(new Error("socket reset by peer")), + } as any; + const rpcService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + server: mockServer, + logger: mockLogger, + }); + + await expect(rpcService.simulateTransaction({} as any)).rejects.toMatchObject({ + code: "rpc_request_failed", + message: "Soroban RPC simulation failed.", + }); + expect(mockLogger.error).toHaveBeenCalledWith( + "Soroban RPC request failed.", + expect.objectContaining({ operation: "simulation" }) + ); + }); + + it("should time out stalled RPC requests", async () => { + jest.useFakeTimers(); + const mockServer = { + sendTransaction: jest.fn(() => new Promise(() => undefined)), + } as any; + const rpcService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + server: mockServer, + logger: mockLogger, + rpcTimeoutMs: 25, + }); + + const submissionError = rpcService.submitTransaction({} as any).catch((error) => error); + await jest.advanceTimersByTimeAsync(25); + + const error = await submissionError; + expect(error).toBeInstanceOf(InvoiceEscrowContractError); + expect(error).toMatchObject({ code: "rpc_timeout" }); + jest.useRealTimers(); + }); }); });