diff --git a/src/config/data-source.ts b/src/config/data-source.ts index 0541cae..4a210e0 100644 --- a/src/config/data-source.ts +++ b/src/config/data-source.ts @@ -29,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 4f2b507..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"; @@ -7,24 +13,60 @@ import { createAuthRateLimitMiddleware } from "../middleware/rate-limit.middlewa import type { AuthService } from "../services/auth.service"; 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+/=_:.-]+$/; + type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise | void; -const publicKeySchema = Joi.string().trim().required(); +const publicKeySchema = Joi.string() + .trim() + .length(56) + .pattern(STELLAR_PUBLIC_KEY_PATTERN) + .messages({ + "string.length": "publicKey must be 56 characters long", + "string.pattern.base": "publicKey must be a valid Stellar public key", + }) + .required(); const challengeSchema = Joi.object({ publicKey: publicKeySchema, -}).unknown(true); +}) + .unknown(false) + .options({ abortEarly: false, convert: true, stripUnknown: true }); const verifySchema = Joi.object({ publicKey: publicKeySchema, - nonce: Joi.string().trim().required(), - signature: Joi.string().trim().required(), -}).unknown(true); + nonce: Joi.string() + .trim() + .min(16) + .max(256) + .pattern(NONCE_PATTERN) + .messages({ + "string.pattern.base": "nonce contains unsupported characters", + }) + .required(), + signature: Joi.string() + .trim() + // 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({ + "string.pattern.base": "signature contains unsupported characters", + }) + .required(), +}) + .unknown(false) + .options({ abortEarly: false, convert: true, stripUnknown: true }); function wrapAuthHandler( routeName: string, handler: AsyncRouteHandler, - logger: AppLogger, + logger: AppLogger ): RequestHandler { return async (req, res, next) => { try { @@ -62,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 47c8e80..2c3af49 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -1,4 +1,4 @@ -import { DataSource, In } 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"; @@ -25,6 +25,8 @@ export interface InvoiceRepositoryContract { order?: { [key: string]: "ASC" | "DESC" }; relations?: string[]; }): 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; @@ -42,7 +44,7 @@ export interface NotificationSink { userId: string, type: NotificationType, title: string, - message: string, + message: string ): Promise; } @@ -159,7 +161,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]: [], @@ -196,7 +202,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))); @@ -212,6 +224,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 */ @@ -344,9 +369,15 @@ export class InvoiceService { * Update an invoice (only draft invoices can be updated) */ async updateInvoice(input: UpdateInvoiceInput): Promise { + 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); + } + try { const invoice = await this.invoiceRepository.findOne({ - where: { id: input.invoiceId }, + where: { id: invoiceId }, }); if (!invoice) { @@ -354,7 +385,7 @@ export class InvoiceService { } // Verify ownership - if (invoice.sellerId !== input.sellerId) { + if (invoice.sellerId !== sellerId) { throw new ServiceError( "unauthorized_invoice_access", "You can only update your own invoices", @@ -371,31 +402,43 @@ export class InvoiceService { ); } - // Update fields - if (input.customerName) { - invoice.customerName = input.customerName; + // 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) { - invoice.amount = input.amount; - invoice.discountRate = input.discountRate || invoice.discountRate; + 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) { - invoice.discountRate = input.discountRate; + } else if (input.discountRate !== undefined) { + invoice.discountRate = input.discountRate.trim(); invoice.netAmount = this.calculateNetAmount(invoice.amount, invoice.discountRate); } - if (input.dueDate) { + 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) { - invoice.riskScore = input.riskScore; + 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 process', { error }); - throw new AppError(500, 'Processing failed', 'PROCESSING_FAILED', { error }); + logger.error("Failed to update invoice", { error, invoiceId, sellerId }); + throw new ServiceError("invoice_update_failed", "Failed to update invoice", 500); } } @@ -439,6 +482,87 @@ export class InvoiceService { } } + /** + * 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 }, + relations: ["seller"], + }); + 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); + + const seller = invoice.seller as unknown as User; + logInvoiceTransition(logger, { + invoiceId: updated.id, + fromState: previousStatus, + toState: InvoiceStatus.REJECTED, + actorWallet: seller?.stellarAddress ?? "admin", + reason: "admin_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) */ @@ -510,72 +634,6 @@ export class InvoiceService { } } - /** - * Reject an invoice (admin operation) - */ - 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); - } - - const invoice = await this.invoiceRepository.findOne({ - where: { id: invoiceId }, - relations: ["seller"], - }); - - if (!invoice) { - throw new ServiceError("invoice_not_found", "Invoice not found", 404); - } - - if (invoice.status === InvoiceStatus.REJECTED) { - throw new ServiceError( - "invoice_already_rejected", - "Invoice has already been rejected", - 409, - ); - } - - if (!this.isValidTransition(invoice.status, InvoiceStatus.REJECTED)) { - throw new ServiceError( - "invalid_status_transition", - `Cannot transition invoice status from ${invoice.status} to ${InvoiceStatus.REJECTED}`, - 409, - ); - } - - const previousStatus = invoice.status; - invoice.status = InvoiceStatus.REJECTED; - invoice.rejectionReason = rejectionReason; - - const saved = await this.invoiceRepository.save(invoice); - - const seller = invoice.seller as unknown as User; - logInvoiceTransition(logger, { - invoiceId: saved.id, - fromState: previousStatus, - toState: InvoiceStatus.REJECTED, - actorWallet: seller?.stellarAddress ?? "admin", - reason: "admin_rejected", - }); - - if (this.notificationSink) { - await this.notificationSink.createNotification( - invoice.sellerId, - NotificationType.INVOICE, - "Invoice Rejected", - `Your invoice was rejected: ${rejectionReason}`, - ); - } - - return this.toDTO(saved); - } - /** * Publish several draft invoices in one atomic step. * @@ -590,21 +648,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 ); } @@ -614,21 +685,33 @@ export class InvoiceService { const publishable: Array<{ invoice: Invoice; sellerWallet: string }> = []; const rejections: BatchPublishRejection[] = []; - // Batch fetch: single query with In(uniqueIds) avoids N round-trips + // 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 { - const invoices = await this.invoiceRepository.find({ - where: { id: In(uniqueIds) }, - relations: ["seller"], - }); - const byId = new Map(invoices.map((inv) => [inv.id, inv])); + 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: byId.get(invoiceId) ?? null, + 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) { @@ -655,7 +738,7 @@ export class InvoiceService { throw new ServiceError( "kyc_approval_required", "KYC approval is required to publish invoices", - 403, + 403 ); } @@ -686,21 +769,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, { @@ -805,11 +897,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); @@ -912,8 +1000,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 f6866e2..b57f126 100644 --- a/src/services/stellar/invoice-escrow-contract.service.ts +++ b/src/services/stellar/invoice-escrow-contract.service.ts @@ -21,12 +21,43 @@ 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 DEFAULT_CONFIRMATION_POLL_MS = 1000; +const DEFAULT_CONFIRMATION_ATTEMPTS = 20; + +/** + * RPC failures reach the HTTP layer as ServiceError so the error middleware can + * map them to a gateway status; a bespoke error class would fall through to a + * generic 500. The timeout guard is layered on top of that same contract. + */ +const RPC_OPERATIONS = { + simulation: { + failureCode: "soroban_simulation_failed", + logMessage: "Soroban simulateTransaction call failed.", + failureMessage: "Failed to simulate the transaction against the Soroban RPC endpoint.", + }, + submission: { + failureCode: "soroban_submission_failed", + logMessage: "Soroban sendTransaction call failed.", + failureMessage: "Failed to submit the transaction to the Soroban RPC endpoint.", + }, +} as const; + +type RpcOperation = keyof typeof RPC_OPERATIONS; +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; @@ -35,6 +66,7 @@ export interface InvoiceEscrowContractServiceDependencies { platformSecretKey?: string; server?: SorobanRpc.Server; logger?: AppLogger; + rpcTimeoutMs?: number; confirmationPollMs?: number; confirmationAttempts?: number; } @@ -46,12 +78,13 @@ export class InvoiceEscrowContractService { private readonly networkPassphrase?: string; private readonly platformSecretKey?: string; private readonly logger: AppLogger; + private readonly rpcTimeoutMs: number; private readonly confirmationPollMs: number; private readonly confirmationAttempts: number; constructor( dependenciesOrContractId: string | InvoiceEscrowContractServiceDependencies, - logger?: AppLogger, + logger?: AppLogger ) { if (typeof dependenciesOrContractId === "string") { if (!dependenciesOrContractId || !dependenciesOrContractId.trim()) { @@ -60,8 +93,9 @@ export class InvoiceEscrowContractService { this.contractId = dependenciesOrContractId.trim(); this.contract = new Contract(this.contractId); this.logger = logger ?? globalLogger; - this.confirmationPollMs = 1000; - this.confirmationAttempts = 20; + this.rpcTimeoutMs = DEFAULT_RPC_TIMEOUT_MS; + this.confirmationPollMs = DEFAULT_CONFIRMATION_POLL_MS; + this.confirmationAttempts = DEFAULT_CONFIRMATION_ATTEMPTS; } else { if (!dependenciesOrContractId.contractId || !dependenciesOrContractId.contractId.trim()) { throw new Error("contractId is required."); @@ -78,23 +112,113 @@ export class InvoiceEscrowContractService { }); } this.logger = dependenciesOrContractId.logger ?? logger ?? globalLogger; - this.confirmationPollMs = dependenciesOrContractId.confirmationPollMs ?? 1000; - this.confirmationAttempts = dependenciesOrContractId.confirmationAttempts ?? 20; + 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; + this.confirmationPollMs = + dependenciesOrContractId.confirmationPollMs ?? DEFAULT_CONFIRMATION_POLL_MS; + this.confirmationAttempts = + dependenciesOrContractId.confirmationAttempts ?? DEFAULT_CONFIRMATION_ATTEMPTS; + } + } + + 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 parseStroopAmount(amount: bigint | number | string, fieldName = "amountStroops"): bigint { + private normalizeAmount(amount: bigint | number | string): bigint { + let normalized: bigint; try { - const parsed = typeof amount === "bigint" ? amount : BigInt(amount); - if (parsed <= 0n) { - throw new Error(`${fieldName} must be positive.`); + if (typeof amount === "number" && (!Number.isSafeInteger(amount) || amount <= 0)) { + throw new Error("unsafe numeric amount"); } - return parsed; - } catch (error) { - if (error instanceof Error && error.message.includes("must be positive")) { - throw error; + if (typeof amount === "string" && !/^\d+$/.test(amount.trim())) { + throw new Error("invalid amount string"); } - throw new Error(`Invalid ${fieldName}: ${String(amount)}`); + 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: RpcOperation, work: () => Promise): Promise { + const { failureCode, logMessage, failureMessage } = RPC_OPERATIONS[operation]; + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + work(), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject( + new ServiceError( + "soroban_rpc_timeout", + `Soroban RPC ${operation} timed out after ${this.rpcTimeoutMs}ms.`, + 504 + ) + ); + }, this.rpcTimeoutMs); + }), + ]); + } catch (error) { + this.logger.error(logMessage, { + operation, + sorobanContractId: this.contractId, + error: error instanceof Error ? error.message : "Unknown error", + }); + if (error instanceof ServiceError) throw error; + throw new ServiceError(failureCode, failureMessage, 502); + } finally { + if (timeout) clearTimeout(timeout); } } @@ -106,30 +230,19 @@ export class InvoiceEscrowContractService { sellerAddress: string, amountStroops: bigint | number | string, dueDateTimestamp: number, - paymentTokenAddress: string, + paymentTokenAddress: string ): xdr.Operation { - if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { - throw new Error("invoiceId is required."); - } - if (!sellerAddress || typeof sellerAddress !== "string" || !sellerAddress.trim()) { - throw new Error("sellerAddress is required."); - } - if (!Number.isFinite(dueDateTimestamp) || dueDateTimestamp <= 0) { - throw new Error("dueDateTimestamp must be a positive number."); - } - if (!paymentTokenAddress || typeof paymentTokenAddress !== "string" || !paymentTokenAddress.trim()) { - throw new Error("paymentTokenAddress is required."); - } - - const amountBigInt = this.parseStroopAmount(amountStroops, "amountStroops"); + const normalizedInvoiceId = this.normalizeInvoiceId(invoiceId); + const amountBigInt = this.normalizeAmount(amountStroops); + const dueDate = this.normalizeDueDate(dueDateTimestamp); return this.contract.call( "create_escrow", - nativeToScVal(invoiceId.trim(), { type: "symbol" }), - new Address(sellerAddress.trim()).toScVal(), + nativeToScVal(normalizedInvoiceId, { type: "symbol" }), + this.toAddressScVal(sellerAddress, "sellerAddress"), nativeToScVal(amountBigInt, { type: "i128" }), - nativeToScVal(dueDateTimestamp, { type: "u64" }), - new Address(paymentTokenAddress.trim()).toScVal(), + nativeToScVal(dueDate, { type: "u64" }), + this.toAddressScVal(paymentTokenAddress, "paymentTokenAddress") ); } @@ -139,22 +252,16 @@ export class InvoiceEscrowContractService { public buildFundEscrowTx( invoiceId: string, investorAddress: string, - amountStroops: bigint | number | string, + amountStroops: bigint | number | string ): xdr.Operation { - if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { - throw new Error("invoiceId is required."); - } - if (!investorAddress || typeof investorAddress !== "string" || !investorAddress.trim()) { - throw new Error("investorAddress is required."); - } - - const amountBigInt = this.parseStroopAmount(amountStroops, "amountStroops"); + const normalizedInvoiceId = this.normalizeInvoiceId(invoiceId); + const amountBigInt = this.normalizeAmount(amountStroops); return this.contract.call( "fund_escrow", - nativeToScVal(invoiceId.trim(), { type: "symbol" }), - new Address(investorAddress.trim()).toScVal(), - nativeToScVal(amountBigInt, { type: "i128" }), + nativeToScVal(normalizedInvoiceId, { type: "symbol" }), + this.toAddressScVal(investorAddress, "investorAddress"), + nativeToScVal(amountBigInt, { type: "i128" }) ); } @@ -164,22 +271,16 @@ export class InvoiceEscrowContractService { public buildRecordPaymentTx( invoiceId: string, payerAddress: string, - amountStroops: bigint | number | string, + amountStroops: bigint | number | string ): xdr.Operation { - if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { - throw new Error("invoiceId is required."); - } - if (!payerAddress || typeof payerAddress !== "string" || !payerAddress.trim()) { - throw new Error("payerAddress is required."); - } - - const amountBigInt = this.parseStroopAmount(amountStroops, "amountStroops"); + const normalizedInvoiceId = this.normalizeInvoiceId(invoiceId); + const amountBigInt = this.normalizeAmount(amountStroops); return this.contract.call( "record_payment", - nativeToScVal(invoiceId.trim(), { type: "symbol" }), - new Address(payerAddress.trim()).toScVal(), - nativeToScVal(amountBigInt, { type: "i128" }), + nativeToScVal(normalizedInvoiceId, { type: "symbol" }), + this.toAddressScVal(payerAddress, "payerAddress"), + nativeToScVal(amountBigInt, { type: "i128" }) ); } @@ -187,13 +288,9 @@ export class InvoiceEscrowContractService { * Build the Soroban contract invocation operation for settling an escrow. */ public buildSettleEscrowTx(invoiceId: string): xdr.Operation { - if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { - throw new Error("invoiceId is required."); - } - return this.contract.call( "settle_escrow", - nativeToScVal(invoiceId.trim(), { type: "symbol" }), + nativeToScVal(this.normalizeInvoiceId(invoiceId), { type: "symbol" }) ); } @@ -201,27 +298,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."); } - let simResponse: Awaited>; - try { - simResponse = await this.rpcServer.simulateTransaction(transaction); - } catch (error) { - this.logger.error("Soroban simulateTransaction call failed.", { - sorobanContractId: this.contractId, - error: error instanceof Error ? error.message : String(error), - }); - throw new ServiceError( - "soroban_simulation_failed", - "Failed to simulate the transaction against the Soroban RPC endpoint.", - 502, - ); - } - + const simResponse = await this.executeRpc("simulation", () => + this.rpcServer!.simulateTransaction(transaction) + ); const successResponse = simResponse as unknown as { minResourceFee?: string; cost?: { cpuInsns?: string; memBytes?: string }; @@ -249,27 +334,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."); } - let response: Awaited>; - try { - response = await this.rpcServer.sendTransaction(transaction); - } catch (error) { - this.logger.error("Soroban sendTransaction call failed.", { - sorobanContractId: this.contractId, - error: error instanceof Error ? error.message : String(error), - }); - throw new ServiceError( - "soroban_submission_failed", - "Failed to submit the transaction to the Soroban RPC endpoint.", - 502, - ); - } - + const response = await this.executeRpc("submission", () => + this.rpcServer!.sendTransaction(transaction) + ); return { status: response.status, txHash: response.hash, @@ -339,35 +412,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 = this.parseStroopAmount(input.amountStroops, "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 2fd96be..8c99dc7 100644 --- a/tests/e2e/full-flow.e2e.test.ts +++ b/tests/e2e/full-flow.e2e.test.ts @@ -375,9 +375,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"); }); }); @@ -395,9 +393,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 5b69a43..bb9ca89 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -302,6 +302,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 ============ @@ -352,7 +385,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 () => { @@ -372,7 +408,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); @@ -392,7 +431,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); @@ -439,7 +481,10 @@ describe("InvoiceService", () => { const invoiceWithPendingKYC = { ...publishableInvoice, status: InvoiceStatus.DRAFT, - seller: { kycStatus: "pending", stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV" }, + seller: { + kycStatus: "pending", + stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV", + }, }; mockInvoiceRepository.findOne.mockResolvedValue(invoiceWithPendingKYC); @@ -462,7 +507,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 77cd53f..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 = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +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 = "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526"; 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 fa73911..fd5a209 100644 --- a/tests/unit/invoice-batch-publish.test.ts +++ b/tests/unit/invoice-batch-publish.test.ts @@ -45,22 +45,20 @@ describe("InvoiceService.publishInvoicesBatch", () => { let transactionCommitted: boolean; let service: InvoiceService; - /** Stub the repository so a batched `find` resolves the requested invoice ids. */ + /** Stub the repository's set-based batch lookup. */ function stubInvoices(invoices: Invoice[]) { - repository.find.mockImplementation(async ({ where }: { where: { id: { value: string[] } } }) => { - const ids = new Set(where.id.value); - return invoices.filter((invoice) => ids.has(invoice.id)); - }); + 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(), @@ -95,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); } @@ -109,7 +110,7 @@ describe("InvoiceService.publishInvoicesBatch", () => { sellerId: SELLER_ID, }); - expect(repository.find).toHaveBeenCalledTimes(1); + expect(repository.findManyByIds).toHaveBeenCalledTimes(1); expect(repository.findOne).not.toHaveBeenCalled(); }); @@ -123,13 +124,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 }); }); }); @@ -147,7 +149,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(); @@ -159,7 +161,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); @@ -167,15 +169,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); }); @@ -198,8 +196,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"); @@ -213,8 +212,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" }, ]); @@ -252,17 +252,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(); @@ -276,7 +278,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 37a8929..6f66745 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"; import { ServiceError } from "../../../../src/utils/service-error"; @@ -35,23 +38,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."); - expect( - () => new InvoiceEscrowContractService({ contractId: " " }), - ).toThrow("contractId is required."); + () => + new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + rpcTimeoutMs: 0, + }) + ).toThrow("rpcTimeoutMs must be a positive integer."); }); }); @@ -62,15 +67,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"); @@ -104,15 +105,15 @@ describe("InvoiceEscrowContractService", () => { expect(() => service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, 0n, TEST_DUE_DATE, TEST_TOKEN), - ).toThrow("amountStroops must be positive."); + ).toThrow("amountStroops must be a positive i128 integer."); expect(() => service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, -100n, TEST_DUE_DATE, TEST_TOKEN), - ).toThrow("amountStroops must be positive."); + ).toThrow("amountStroops must be a positive i128 integer."); expect(() => service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, TEST_AMOUNT_STROOPS, 0, TEST_TOKEN), - ).toThrow("dueDateTimestamp must be a positive number."); + ).toThrow("dueDateTimestamp must be a positive integer."); expect(() => service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, TEST_AMOUNT_STROOPS, TEST_DUE_DATE, ""), @@ -144,7 +145,7 @@ describe("InvoiceEscrowContractService", () => { sorobanContractId: ESCROW_CONTRACT_ID, sellerAddress: TEST_SELLER, amountStroops: TEST_AMOUNT_STROOPS.toString(), - }, + } ); }); @@ -179,11 +180,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(); @@ -193,27 +190,20 @@ describe("InvoiceEscrowContractService", () => { expect(BigInt(scValToNative(args[2]))).toBe(TEST_AMOUNT_STROOPS); }); - it("validates required inputs for buildFundEscrowTx", () => { - expect(() => service.buildFundEscrowTx("", TEST_SELLER, TEST_AMOUNT_STROOPS)).toThrow( - "invoiceId is required.", - ); - expect(() => service.buildFundEscrowTx(TEST_INVOICE_ID, "", TEST_AMOUNT_STROOPS)).toThrow( - "investorAddress is required.", - ); - expect(() => service.buildFundEscrowTx(TEST_INVOICE_ID, TEST_SELLER, 0n)).toThrow( - "amountStroops must be positive.", + 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(); @@ -229,7 +219,7 @@ describe("InvoiceEscrowContractService", () => { "payerAddress is required.", ); expect(() => service.buildRecordPaymentTx(TEST_INVOICE_ID, TEST_SELLER, -5n)).toThrow( - "amountStroops must be positive.", + "amountStroops must be a positive i128 integer.", ); }); }); @@ -237,11 +227,7 @@ 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(); @@ -249,8 +235,10 @@ describe("InvoiceEscrowContractService", () => { expect(scValToNative(args[0])).toBe(TEST_INVOICE_ID); }); - it("validates required inputs for buildSettleEscrowTx", () => { - expect(() => service.buildSettleEscrowTx("")).toThrow("invoiceId is required."); + it("should reject empty invoice ids", () => { + expect(() => service.buildSettleEscrowTx(" ")).toThrow( + expect.objectContaining({ code: "invalid_invoice_id" }) + ); }); }); @@ -337,6 +325,27 @@ describe("InvoiceEscrowContractService", () => { expect.objectContaining({ sorobanContractId: ESCROW_CONTRACT_ID }), ); }); + it("times out a stalled RPC request", 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(ServiceError); + expect(error).toMatchObject({ code: "soroban_rpc_timeout", statusCode: 504 }); + jest.useRealTimers(); + }); }); describe("waitForTransactionConfirmation", () => {