From 9f341635853094ed2a918660893552f0e23c514e Mon Sep 17 00:00:00 2001 From: Nursca Date: Thu, 3 Sep 2026 15:52:23 +0100 Subject: [PATCH 1/2] feat: add TypeScript parsing utilities for JWT validation tests --- check_acorn.js | 21 +++++++++++++++++ check_ast.js | 27 +++++++++++++++++++++ check_ast2.js | 49 +++++++++++++++++++++++++++++++++++++++ check_ast3.js | 32 +++++++++++++++++++++++++ check_braces.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++ check_braces2.js | 57 +++++++++++++++++++++++++++++++++++++++++++++ check_braces3.js | 48 ++++++++++++++++++++++++++++++++++++++ check_templates.js | 39 +++++++++++++++++++++++++++++++ check_tokens.js | 22 ++++++++++++++++++ 9 files changed, 353 insertions(+) create mode 100644 check_acorn.js create mode 100644 check_ast.js create mode 100644 check_ast2.js create mode 100644 check_ast3.js create mode 100644 check_braces.js create mode 100644 check_braces2.js create mode 100644 check_braces3.js create mode 100644 check_templates.js create mode 100644 check_tokens.js diff --git a/check_acorn.js b/check_acorn.js new file mode 100644 index 0000000..9fddb80 --- /dev/null +++ b/check_acorn.js @@ -0,0 +1,21 @@ +const acorn = require('acorn'); +const fs = require('fs'); +const src = fs.readFileSync('tests/integration/auth-jwt-validation.test.ts', 'utf8'); + +// Strip TypeScript-specific syntax for acorn +// Let's try parsing with allowReturnOutsideFunction and other options +try { + const ast = acorn.parse(src, { + ecmaVersion: 2022, + sourceType: 'module', + allowReturnOutsideFunction: true, + allowImportExportEverywhere: true, + allowAwaitOutsideFunction: true, + allowSuperOutsideMethod: true, + locations: true, + }); + console.log('Parse OK'); +} catch (e) { + console.log('Parse error: ' + e.message); + console.log('At line ' + e.loc?.line + ' col ' + e.loc?.column); +} diff --git a/check_ast.js b/check_ast.js new file mode 100644 index 0000000..e65c97f --- /dev/null +++ b/check_ast.js @@ -0,0 +1,27 @@ +const ts = require('typescript'); +const fs = require('fs'); +const src = fs.readFileSync('tests/integration/auth-jwt-validation.test.ts', 'utf8'); + +// Parse the source file with error recovery (scanning for syntax errors) +const sourceFile = ts.createSourceFile( + 'test.ts', + src, + ts.ScriptTarget.ES2022, + /*setParentNodes*/ false, + ts.ScriptKind.TS +); + +// Get diagnostics from the source file's parse +const parseDiagnostics = sourceFile.parseDiagnostics || []; +console.log('Parse diagnostics:', parseDiagnostics.length); +parseDiagnostics.forEach(d => { + const pos = sourceFile.getLineAndCharacterOfPosition(d.start); + console.log(' Line', pos.line + 1, 'col', pos.character + 1 + ':', ts.flattenDiagnosticMessageText(d.messageText, '\n')); +}); + +// Check the last token +const lastToken = ts.getLastToken(sourceFile); +console.log('\nLast token kind:', ts.SyntaxKind[lastToken.kind], 'at pos', lastToken.getStart()); +const lines = src.split('\n'); +console.log('Lines in file:', lines.length); +console.log('Last line:', JSON.stringify(lines[lines.length - 1])); diff --git a/check_ast2.js b/check_ast2.js new file mode 100644 index 0000000..a4191de --- /dev/null +++ b/check_ast2.js @@ -0,0 +1,49 @@ +const ts = require('typescript'); +const fs = require('fs'); + +const filePath = 'tests/integration/auth-jwt-validation.test.ts'; +const src = fs.readFileSync(filePath, 'utf8'); + +// Create program with minimal options +const options = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + strict: true, + esModuleInterop: true, + skipLibCheck: true, + experimentalDecorators: true, + emitDecoratorMetadata: true, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + baseUrl: '.', + paths: { '@/*': ['src/*'] }, + types: ['node', 'express', 'jest'], + noEmit: true, +}; + +const program = ts.createProgram([filePath], options); +const sourceFile = program.getSourceFile(filePath); + +if (sourceFile) { + // Only syntax diagnostics (from parseDiagnostics) + console.log('=== Source file parse diagnostics ==='); + const parseDiags = sourceFile.parseDiagnostics; + if (parseDiags && parseDiags.length > 0) { + parseDiags.forEach(d => { + const pos = sourceFile.getLineAndCharacterOfPosition(d.start); + const cat = ts.DiagnosticCategory[d.category]; + console.log(' [' + cat + '] Line ' + (pos.line + 1) + ' col ' + (pos.character + 1) + ': ' + ts.flattenDiagnosticMessageText(d.messageText, '\n')); + }); + } else { + console.log(' No parse diagnostics'); + } + + // Full diagnostics from program + console.log('\n=== Program diagnostics ==='); + const allDiags = ts.getPreEmitDiagnostics(program, sourceFile); + allDiags.forEach(d => { + const pos = sourceFile.getLineAndCharacterOfPosition(d.start); + const cat = ts.DiagnosticCategory[d.category]; + console.log(' [' + cat + '] Line ' + (pos.line + 1) + ' col ' + (pos.character + 1) + ': ' + ts.flattenDiagnosticMessageText(d.messageText, '\n')); + }); + console.log('Total diagnostics:', allDiags.length); +} diff --git a/check_ast3.js b/check_ast3.js new file mode 100644 index 0000000..8614e6b --- /dev/null +++ b/check_ast3.js @@ -0,0 +1,32 @@ +const ts = require('typescript'); +const fs = require('fs'); +const src = fs.readFileSync('tests/integration/auth-jwt-validation.test.ts', 'utf8'); + +// Parse without error recovery +const sourceFile = ts.createSourceFile('test.ts', src, ts.ScriptTarget.ES2022, false, ts.ScriptKind.TS); + +// Walk the AST and track describe/it blocks +function walk(node, depth) { + if (ts.isExpressionStatement(node)) { + // Check if it's a describe() or it() call + if (ts.isCallExpression(node.expression)) { + const callee = node.expression.expression; + if (callee && ts.isIdentifier(callee)) { + const name = callee.text; + if (name === 'describe' || name === 'it' || name === 'it' || name === 'it.each') { + // Check if the callback has balanced braces + const args = node.expression.arguments; + if (args.length > 0 && ts.isArrowFunction(args[args.length - 1])) { + const arrow = args[args.length - 1]; + if (arrow.body && ts.isBlock(arrow.body)) { + console.log(name + ' at pos ' + node.getStart() + ' end: ' + node.getEnd() + ' (body span: ' + arrow.body.getStart() + '-' + arrow.body.getEnd() + ')'); + } + } + } + } + } + } + ts.forEachChild(node, child => walk(child, depth + 1)); +} + +walk(sourceFile, 0); diff --git a/check_braces.js b/check_braces.js new file mode 100644 index 0000000..5b99a73 --- /dev/null +++ b/check_braces.js @@ -0,0 +1,58 @@ +const ts = require('typescript'); +const fs = require('fs'); +const src = fs.readFileSync('tests/integration/auth-jwt-validation.test.ts', 'utf8'); + +const sourceFile = ts.createSourceFile('test.ts', src, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); +const scanner = ts.createScanner(ts.ScriptTarget.ES2022, false, ts.LanguageVariant.Standard, src); + +let token; +let inTemplateExpr = false; +let realOpen = 0; +let realClose = 0; +let fakeClose = 0; +let openStack = []; +let closeStack = []; + +while ((token = scanner.scan()) !== ts.SyntaxKind.EndOfFileToken) { + const pos = scanner.getStartPos(); + const lineInfo = sourceFile.getLineAndCharacterOfPosition(pos); + const line = lineInfo.line + 1; + const col = lineInfo.character + 1; + const lines = src.split('\n'); + const ctx = lines[line - 1]?.substring(col - 1, col + 20).replace(/\n/g, ''); + + if (!inTemplateExpr) { + if (token === ts.SyntaxKind.OpenBraceToken) { + realOpen++; + openStack.push({ line, col, ctx }); + } + if (token === ts.SyntaxKind.CloseBraceToken) { + realClose++; + if (openStack.length > 0) { + closeStack.push({ line, col, matched: openStack.pop(), ctx }); + } else { + console.log('EXTRA } at line ' + line + ' col ' + col + ' context: ' + ctx); + } + } + if (token === ts.SyntaxKind.TemplateHead || token === ts.SyntaxKind.TemplateMiddle) { + inTemplateExpr = true; + } + } else { + if (token === ts.SyntaxKind.CloseBraceToken) { + fakeClose++; + inTemplateExpr = false; + } + } +} + +console.log('Real OpenBraceToken:', realOpen); +console.log('Real CloseBraceToken:', realClose); +console.log('Template CloseBrace (fake):', fakeClose); +console.log('Net unclosed (real):', realOpen - realClose); +console.log('Unclosed braces:', openStack.length); +if (openStack.length > 0) { + console.log('Unclosed brace locations:'); + openStack.forEach(u => { + console.log(' line ' + u.line + ' col ' + u.col + ' context: ' + u.ctx); + }); +} diff --git a/check_braces2.js b/check_braces2.js new file mode 100644 index 0000000..921ba54 --- /dev/null +++ b/check_braces2.js @@ -0,0 +1,57 @@ +const ts = require('typescript'); +const fs = require('fs'); +const src = fs.readFileSync('tests/integration/auth-jwt-validation.test.ts', 'utf8'); + +const sourceFile = ts.createSourceFile('test.ts', src, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); +const scanner = ts.createScanner(ts.ScriptTarget.ES2022, false, ts.LanguageVariant.Standard, src); + +let token; +let stack = []; // stack of {line, col, kind: 'brace'|'template'} +let inTemplate = false; + +// Track template expression depth +let templateExprDepth = 0; + +while ((token = scanner.scan()) !== ts.SyntaxKind.EndOfFileToken) { + const pos = scanner.getStartPos(); + const lineInfo = sourceFile.getLineAndCharacterOfPosition(pos); + const line = lineInfo.line + 1; + const col = lineInfo.character + 1; + const lines = src.split('\n'); + const ctx = (lines[line-1] || '').substring(col-1, col+20); + + if (inTemplate) { + // Inside template expression (${...}) + if (token === ts.SyntaxKind.CloseBraceToken) { + // This } closes the template expression + inTemplate = false; + } + // Other tokens inside template expression are part of the expression - ignore + continue; + } + + // Check if this starts a template literal with substitution + if (token === ts.SyntaxKind.TemplateHead || token === ts.SyntaxKind.TemplateMiddle) { + // TemplateHead/TemplateMiddle is followed by ${ - the scanner will produce + // expression tokens next, then a CloseBraceToken for the } + inTemplate = true; + continue; + } + + // Not in template expression - count braces normally + if (token === ts.SyntaxKind.OpenBraceToken) { + stack.push({ line, col, ctx }); + } + if (token === ts.SyntaxKind.CloseBraceToken) { + if (stack.length > 0) { + stack.pop(); + } else { + console.log('EXTRA } at line ' + line + ' col ' + col + ' ctx: ' + ctx); + } + } +} + +console.log('\nUnclosed braces: ' + stack.length); +stack.forEach(s => { + console.log(' line ' + s.line + ' col ' + s.col + ' ctx: ' + s.ctx); +}); diff --git a/check_braces3.js b/check_braces3.js new file mode 100644 index 0000000..75a1475 --- /dev/null +++ b/check_braces3.js @@ -0,0 +1,48 @@ +const ts = require('typescript'); +const fs = require('fs'); +const src = fs.readFileSync('tests/integration/auth-jwt-validation.test.ts', 'utf8'); +const sourceFile = ts.createSourceFile('test.ts', src, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); +const scanner = ts.createScanner(ts.ScriptTarget.ES2022, false, ts.LanguageVariant.Standard, src); + +let token; +let stack = []; +let inTemplate = false; +const lines = src.split('\n'); + +while ((token = scanner.scan()) !== ts.SyntaxKind.EndOfFileToken) { + const pos = scanner.getStartPos(); + const lineInfo = sourceFile.getLineAndCharacterOfPosition(pos); + const lineNum = lineInfo.line + 1; + const col = lineInfo.character + 1; + const ctx = (lines[lineNum-1] || '').substring(col-1, col+30); + + if (inTemplate) { + if (token === ts.SyntaxKind.CloseBraceToken) { + inTemplate = false; + } + continue; + } + + if (token === ts.SyntaxKind.TemplateHead || token === ts.SyntaxKind.TemplateMiddle) { + inTemplate = true; + continue; + } + + if (token === ts.SyntaxKind.OpenBraceToken) { + stack.push({ line: lineNum, col, ctx }); + } + if (token === ts.SyntaxKind.CloseBraceToken) { + if (stack.length > 0) { + const opened = stack.pop(); + // Only show braces opened at depth 0-2 (top-level structures) + if (stack.length <= 2) { + console.log(' CLOSE line ' + lineNum + ' (closed { from line ' + opened.line + ') stack depth now: ' + stack.length); + } + } else { + console.log('EXTRA } at line ' + lineNum + ' ctx: ' + ctx); + } + } +} + +console.log('\nUnclosed:', stack.length); +stack.forEach(s => console.log(' line ' + s.line)); diff --git a/check_templates.js b/check_templates.js new file mode 100644 index 0000000..e8ad97d --- /dev/null +++ b/check_templates.js @@ -0,0 +1,39 @@ +const ts = require('typescript'); +const fs = require('fs'); +const src = fs.readFileSync('tests/integration/auth-jwt-validation.test.ts', 'utf8'); +const sourceFile = ts.createSourceFile('test.ts', src, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); +const scanner = ts.createScanner(ts.ScriptTarget.ES2022, false, ts.LanguageVariant.Standard, src); +let token; +let inTemplate = false; +const lines = src.split('\n'); + +while ((token = scanner.scan()) !== ts.SyntaxKind.EndOfFileToken) { + const pos = scanner.getStartPos(); + const li = sourceFile.getLineAndCharacterOfPosition(pos); + const lineNum = li.line + 1; + const col = li.character + 1; + const ctx = (lines[lineNum-1] || '').substring(col-1, col+30); + + // Print template-related tokens + if (token === ts.SyntaxKind.TemplateHead) { + console.log('TemplateHead L' + lineNum + ' val: "' + scanner.getTokenValue() + '"'); + inTemplate = true; + } else if (token === ts.SyntaxKind.TemplateMiddle) { + console.log('TemplateMiddle L' + lineNum + ' val: "' + scanner.getTokenValue() + '"'); + inTemplate = true; + } else if (token === ts.SyntaxKind.CloseBraceToken) { + if (inTemplate) { + console.log('CLOSE_BRACE (template) L' + lineNum); + inTemplate = false; + } + } + if (token === ts.SyntaxKind.TemplateTail) { + console.log('TemplateTail L' + lineNum + ' val: "' + scanner.getTokenValue() + '"'); + inTemplate = false; + } + if (token === ts.SyntaxKind.NoSubstitutionTemplateLiteral) { + console.log('NoSubstitutionTemplate L' + lineNum + ' val: "' + scanner.getTokenValue() + '"'); + } +} + +console.log('\nTemplate tracking complete'); diff --git a/check_tokens.js b/check_tokens.js new file mode 100644 index 0000000..c7b3b2e --- /dev/null +++ b/check_tokens.js @@ -0,0 +1,22 @@ +const ts = require('typescript'); +const fs = require('fs'); +const src = fs.readFileSync('tests/integration/auth-jwt-validation.test.ts', 'utf8'); +const sourceFile = ts.createSourceFile('test.ts', src, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); +const scanner = ts.createScanner(ts.ScriptTarget.ES2022, false, ts.LanguageVariant.Standard, src); +const lines = src.split('\n'); + +let token; +let lineNum = 0; + +while ((token = scanner.scan()) !== ts.SyntaxKind.EndOfFileToken) { + const pos = scanner.getStartPos(); + const li = sourceFile.getLineAndCharacterOfPosition(pos); + lineNum = li.line + 1; + const col = li.character + 1; + + // Print tokens around lines 159 and 169 + if (lineNum >= 159 && lineNum <= 172) { + const ctx = (lines[lineNum-1] || '').substring(col-1, col+40); + console.log('L' + lineNum + ':' + col + ' kind=' + token + '(' + ts.SyntaxKind[token] + ') val="' + scanner.getTokenValue() + '" ctx: ' + ctx.trim().substring(0, 60)); + } +} From 799ee4bc608ad5a6fb2dc089a2c846147b065dfb Mon Sep 17 00:00:00 2001 From: Nursca Date: Thu, 3 Sep 2026 17:36:16 +0100 Subject: [PATCH 2/2] Add unit tests for AuthService, DataSource configuration, and InvoiceEscrowContractService hardening - Implement comprehensive tests for AuthService focusing on input sanitization, structured logging, and user upsert behavior. - Create a mock DataSource for testing configuration and initialization logic, ensuring proper error handling and logging. - Enhance InvoiceEscrowContractService tests to validate input sanitization, strict due-date validation, RPC retry logic, and sensitive data handling. --- src/config/data-source.ts | 149 +++++- src/services/auth.service.ts | 219 +++++--- .../invoice-escrow-contract.service.ts | 473 ++++++++++++++---- tests/unit/auth.service.hardening.test.ts | 375 ++++++++++++++ tests/unit/config-data-source.test.ts | 219 ++++++++ ...-escrow-contract.service.hardening.test.ts | 342 +++++++++++++ 6 files changed, 1595 insertions(+), 182 deletions(-) create mode 100644 tests/unit/auth.service.hardening.test.ts create mode 100644 tests/unit/config-data-source.test.ts create mode 100644 tests/unit/services/stellar/invoice-escrow-contract.service.hardening.test.ts diff --git a/src/config/data-source.ts b/src/config/data-source.ts index 0541cae..506b85a 100644 --- a/src/config/data-source.ts +++ b/src/config/data-source.ts @@ -4,31 +4,148 @@ * * This module is intentionally a thin re-export of the application DataSource * defined in `./database`, so the CLI and the running application always share - * exactly one connection configuration. On top of that it performs a - * fast-failing pre-flight check with actionable logging: a misconfigured - * environment otherwise surfaces as an opaque driver error deep inside a - * migration transaction, which is painful to diagnose in CI/CD. + * exactly one connection configuration. + * + * On top of that, this module performs a fast-failing pre-flight check with + * actionable logging: a misconfigured environment otherwise surfaces as an + * opaque driver error deep inside a migration transaction, which is painful to + * diagnose in CI/CD. + * + * The runtime helpers below (`initializeDataSource`, `closeDataSource`, + * `getDataSource`) are idempotent and thread-safe so they can be reused from + * both the long-running HTTP server (`src/index.ts`) and CLI processes without + * risking double-initialization or torn-down connections. */ +import { DataSource } from "typeorm"; import { logger } from "../observability/logger"; +import { AppError } from "../utils/http-error"; import dataSource from "./database"; -// Validate dataSource is properly initialized -if (!dataSource) { - throw new Error("DataSource is not properly initialized. Check database configuration."); +function assertValidDataSource(source: unknown): asserts source is DataSource { + if (!source || typeof source !== "object") { + throw new AppError( + 500, + "DataSource is not properly initialized. Check database configuration.", + "DATASOURCE_INVALID", + { received: typeof source }, + ); + } + + const candidate = source as Partial; + if (typeof candidate.initialize !== "function" || typeof candidate.isInitialized !== "boolean") { + throw new AppError( + 500, + "DataSource is missing required TypeORM methods.", + "DATASOURCE_SHAPE_INVALID", + ); + } } -// Export with error handling wrapper -export default dataSource; +assertValidDataSource(dataSource); + +let initializationPromise: Promise | null = null; + +function describeDataSource(source: DataSource): Record { + const driverType = (source.options as { type?: string } | undefined)?.type ?? "unknown"; + const isProduction = process.env.NODE_ENV === "production"; + return { + driver: driverType, + environment: process.env.NODE_ENV ?? "development", + production: isProduction, + auto_migrations: (source.options as { migrationsRun?: boolean } | undefined)?.migrationsRun === true, + }; +} -// Export a helper to safely initialize the data source -export async function initializeDataSource(): Promise { +async function initializeInternal(source: DataSource): Promise { try { - if (!dataSource.isInitialized) { - await dataSource.initialize(); - logger.info("DataSource initialized successfully"); + if (source.isInitialized) { + logger.debug("DataSource already initialized, reusing connection", describeDataSource(source)); + return source; } + + const start = Date.now(); + await source.initialize(); + const durationMs = Date.now() - start; + + logger.info("DataSource initialized successfully", { + ...describeDataSource(source), + duration_ms: durationMs, + }); + return source; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error("Failed to initialize DataSource", { + ...describeDataSource(source), + error: reason, + }); + throw new AppError( + 500, + `DataSource initialization failed: ${reason}`, + "DATASOURCE_INIT_FAILED", + { reason }, + ); + } +} + +/** + * Initialize the shared DataSource exactly once and reuse the in-flight or + * resolved promise on subsequent calls. Safe to invoke from CLI scripts, the + * HTTP server bootstrap, and workers concurrently. + */ +export async function initializeDataSource(): Promise { + if (dataSource.isInitialized) { + logger.debug("DataSource already initialized, reusing connection", describeDataSource(dataSource)); + return dataSource; + } + + if (!initializationPromise) { + initializationPromise = initializeInternal(dataSource).catch((error) => { + initializationPromise = null; + throw error; + }); + } + + return initializationPromise; +} + +/** + * Close the shared DataSource if it is currently open. Safe to call multiple + * times; missing connections are tolerated so this can be wired into shutdown + * handlers without additional guards. + */ +export async function closeDataSource(): Promise { + if (!dataSource.isInitialized) { + return; + } + + try { + await dataSource.destroy(); + logger.info("DataSource closed successfully", describeDataSource(dataSource)); } catch (error) { - logger.error("Failed to initialize DataSource", { error }); - throw new Error(`DataSource initialization failed: ${error instanceof Error ? error.message : String(error)}`); + const reason = error instanceof Error ? error.message : String(error); + logger.error("Failed to close DataSource", { + ...describeDataSource(dataSource), + error: reason, + }); + throw new AppError( + 500, + `DataSource shutdown failed: ${reason}`, + "DATASOURCE_SHUTDOWN_FAILED", + { reason }, + ); + } finally { + initializationPromise = null; } } + +/** + * Lightweight accessor for callers that only need a quick health probe without + * paying the cost of `initialize()`. Returns `true` when the underlying + * connection is already established. + */ +export function isDataSourceReady(): boolean { + return dataSource.isInitialized; +} + +// Default export preserved for the TypeORM CLI (`-d src/config/data-source.ts`). +export default dataSource; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 21fdf0a..71fd832 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -59,6 +59,11 @@ export interface AuthServiceDependencies { challengeRepository: ChallengeRepositoryContract; config: Pick & { serverKeypair?: Keypair }; logger?: AppLogger; + /** + * Override the clock used for challenge TTL checks. Mostly useful in tests; + * leave unset in production. + */ + now?: () => number; } export interface ChallengeResponse { @@ -84,12 +89,27 @@ export interface VerifyChallengeResponse { user: PublicUser; } +/** Minimum nonce length (chars) accepted by verifyChallenge. */ +const MIN_NONCE_LENGTH = 16; + export class AuthService { private readonly userRepository: UserRepositoryContract; private readonly challengeRepository: ChallengeRepositoryContract; private readonly config: Pick; private readonly logger?: AppLogger; private readonly serverKeypair?: Keypair; + private readonly now: () => number; + + /** + * In-process single-flight cache for user upserts keyed by Stellar address. + * Collapses concurrent `verifyChallenge` flows for the same wallet into a + * single repository write — preventing the unique-index race where two + * parallel "first login" attempts both try to `INSERT` the same row. + * + * Errors are intentionally NOT cached: a transient DB failure on one + * request must not poison subsequent ones. + */ + private readonly userUpsertInflight = new Map>(); constructor(dependencies: AuthServiceDependencies) { this.userRepository = dependencies.userRepository; @@ -97,13 +117,12 @@ export class AuthService { this.config = dependencies.config; this.logger = dependencies.logger; this.serverKeypair = dependencies.config.serverKeypair; + this.now = dependencies.now ?? (() => Date.now()); } async createChallenge(publicKey: string): Promise { try { - const sanitizedKey = publicKey.trim(); - this.assertValidPublicKey(sanitizedKey); - + const sanitizedKey = this.assertValidPublicKey(publicKey); const issuedAt = new Date(); const expiresAt = new Date(issuedAt.getTime() + this.config.auth.challengeTtlMs); @@ -137,10 +156,21 @@ export class AuthService { expiresAt, }); } catch (error) { - this.logger?.error("Failed to persist challenge", { error, stellarAddress: sanitizedKey }); + this.logger?.error("Failed to persist challenge", { + error: error instanceof Error ? error.message : String(error), + stellarAddress: sanitizedKey, + }); throw new HttpError(500, "Failed to create challenge."); } + this.logger?.info("auth.challenge_created", { + wallet: sanitizedKey, + network: this.config.stellar.network, + challenge_ttl_ms: this.config.auth.challengeTtlMs, + issued_at: issuedAt.toISOString(), + expires_at: expiresAt.toISOString(), + }); + return { publicKey: sanitizedKey, nonce, @@ -162,18 +192,13 @@ export class AuthService { input: VerifyChallengeInput, ): Promise { try { - const sanitizedKey = input.publicKey.trim(); - const sanitizedNonce = input.nonce.trim(); - const sanitizedSig = input.signature.trim(); - - this.assertValidPublicKey(sanitizedKey); + const sanitizedKey = this.assertValidPublicKey(input.publicKey); + const sanitizedNonce = this.assertNonEmptyString(input.nonce, "nonce").trim(); + const sanitizedSig = this.assertNonEmptyString(input.signature, "signature").trim(); - if (!sanitizedNonce || sanitizedNonce.length < 16) { + if (sanitizedNonce.length < MIN_NONCE_LENGTH) { throw new HttpError(400, "Invalid nonce."); } - if (!sanitizedSig) { - throw new HttpError(400, "Signature is required."); - } let challenge: ChallengeRecord | null; try { @@ -182,7 +207,10 @@ export class AuthService { hashNonce(sanitizedNonce), ); } catch (error) { - this.logger?.error("Failed to fetch challenge", { error, stellarAddress: sanitizedKey }); + this.logger?.error("Failed to fetch challenge", { + error: error instanceof Error ? error.message : String(error), + stellarAddress: sanitizedKey, + }); throw new HttpError(500, "Failed to verify challenge."); } @@ -198,7 +226,7 @@ export class AuthService { throw new HttpError(401, "Challenge already used."); } - if (challenge.expiresAt.getTime() <= Date.now()) { + if (challenge.expiresAt.getTime() <= this.now()) { throw new HttpError(401, "Challenge expired."); } @@ -231,7 +259,10 @@ export class AuthService { try { consumed = await this.challengeRepository.consume(challenge.id, new Date()); } catch (error) { - this.logger?.error("Failed to consume challenge", { error, challengeId: challenge.id }); + this.logger?.error("Failed to consume challenge", { + error: error instanceof Error ? error.message : String(error), + challengeId: challenge.id, + }); throw new HttpError(500, "Failed to verify challenge."); } @@ -243,7 +274,10 @@ export class AuthService { try { user = await this.upsertUser(sanitizedKey); } catch (error) { - this.logger?.error("Failed to upsert user", { error, stellarAddress: sanitizedKey }); + this.logger?.error("Failed to upsert user", { + error: error instanceof Error ? error.message : String(error), + stellarAddress: sanitizedKey, + }); throw new HttpError(500, "Failed to verify challenge."); } @@ -265,7 +299,16 @@ export class AuthService { user: publicUser, }; } catch (error) { - if (error instanceof HttpError) throw error; + if (error instanceof HttpError) { + this.logger?.info("auth.verify_failed", { + reason: error.message, + // Include only the truncated wallet (never the raw signature or + // nonce) so dashboards can correlate failed attempts without + // leaking sensitive material. + wallet: this.tryTruncateWallet(input?.publicKey), + }); + throw error; + } this.logger?.error("Unhandled error in verifyChallenge", { error: error instanceof Error ? error.message : String(error), }); @@ -274,68 +317,126 @@ export class AuthService { } async getCurrentUser(token: string): Promise { - let payload: AuthTokenPayload; + try { + if (typeof token !== "string") { + throw new HttpError( + 401, + "Invalid or expired token.", + buildAuthFailureDetails(undefined, "missing_token"), + ); + } + const sanitizedToken = token.trim(); + if (!sanitizedToken) { + throw new HttpError( + 401, + "Invalid or expired token.", + buildAuthFailureDetails(token, "missing_token"), + ); + } - const sanitizedToken = token?.trim(); - if (!sanitizedToken) { - throw new HttpError(401, "Invalid or expired token.", buildAuthFailureDetails(token, "missing_token")); - } + let payload: AuthTokenPayload; + try { + payload = jwt.verify(sanitizedToken, this.config.jwt.secret) as AuthTokenPayload; + } catch (error) { + throw new HttpError( + 401, + "Invalid or expired token.", + buildAuthFailureDetails(sanitizedToken, classifyJwtError(error)), + ); + } - try { - payload = jwt.verify(sanitizedToken, this.config.jwt.secret) as AuthTokenPayload; - } catch (error) { - throw new HttpError( - 401, - "Invalid or expired token.", - buildAuthFailureDetails(sanitizedToken, classifyJwtError(error)), - ); - } + if (!payload.sub) { + throw new HttpError( + 401, + "Invalid token payload.", + buildAuthFailureDetails(sanitizedToken, "invalid_token"), + ); + } - if (!payload.sub) { - throw new HttpError( - 401, - "Invalid token payload.", - buildAuthFailureDetails(sanitizedToken, "invalid_token"), - ); - } + let user: User | null; + try { + user = await this.userRepository.findByStellarAddress(payload.sub); + } catch (error) { + this.logger?.error("Failed to fetch user by stellar address", { + error: error instanceof Error ? error.message : String(error), + sub: payload.sub, + }); + throw new HttpError(500, "Failed to fetch current user."); + } - let user: User | null; - try { - user = await this.userRepository.findByStellarAddress(payload.sub); + if (!user) { + throw new HttpError(401, "User no longer exists."); + } + + return toPublicUser(user); } catch (error) { - this.logger?.error("Failed to fetch user by stellar address", { error, sub: payload.sub }); + if (error instanceof HttpError) throw error; + this.logger?.error("Unhandled error in getCurrentUser", { + error: error instanceof Error ? error.message : String(error), + }); throw new HttpError(500, "Failed to fetch current user."); } + } - if (!user) { - throw new HttpError(401, "User no longer exists."); + private assertNonEmptyString(value: unknown, fieldName: string): string { + if (typeof value !== "string") { + throw new HttpError(400, `${fieldName} is required.`); } - - return toPublicUser(user); + return value; } - private assertValidPublicKey(publicKey: string): void { - if (!StrKey.isValidEd25519PublicKey(publicKey)) { + private assertValidPublicKey(publicKey: unknown): string { + if (typeof publicKey !== "string") { + this.logger?.warn("auth.invalid_public_key", { + reason: "not_a_string", + receivedType: typeof publicKey, + }); + throw new HttpError(400, "Invalid Stellar public key."); + } + const sanitized = publicKey.trim(); + if (!sanitized || !StrKey.isValidEd25519PublicKey(sanitized)) { + this.logger?.warn("auth.invalid_public_key", { + reason: "malformed_or_invalid_checksum", + }); throw new HttpError(400, "Invalid Stellar public key."); } + return sanitized; } - private async upsertUser(publicKey: string): Promise { - try { + private tryTruncateWallet(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0) return null; + if (value.length <= 8) return value; + return `${value.slice(0, 4)}...${value.slice(-4)}`; + } + + /** + * Find-or-create the user for a given Stellar address. Concurrent calls for + * the same address are coalesced into a single repository round-trip via + * {@link userUpsertInflight}. + */ + private upsertUser(publicKey: string): Promise { + const cached = this.userUpsertInflight.get(publicKey); + if (cached) return cached; + + const promise = (async () => { const sanitized = publicKey.trim(); const existingUser = await this.userRepository.findByStellarAddress(sanitized); - if (existingUser) { + this.logger?.debug("auth.user_found", { wallet: sanitized }); return existingUser; } - - return await this.userRepository.save({ - stellarAddress: sanitized, + const created = await this.userRepository.save({ stellarAddress: sanitized }); + this.logger?.info("auth.user_upserted", { + wallet: sanitized, + user_id: created.id, }); - } catch (error) { - this.logger?.error("upsertUser failed", { error, publicKey }); - throw error; - } + return created; + })().finally(() => { + this.userUpsertInflight.delete(publicKey); + }); + + this.userUpsertInflight.set(publicKey, promise); + return promise; } private signToken(user: PublicUser): string { diff --git a/src/services/stellar/invoice-escrow-contract.service.ts b/src/services/stellar/invoice-escrow-contract.service.ts index f6866e2..05302a6 100644 --- a/src/services/stellar/invoice-escrow-contract.service.ts +++ b/src/services/stellar/invoice-escrow-contract.service.ts @@ -28,6 +28,37 @@ export type { SettleEscrowParams, }; +/** + * Maximum value (inclusive) accepted for `amountStroops`. Soroban `i128` + * arguments are signed 128-bit integers; we cap at a value comfortably below + * `2^127 - 1` to avoid accidental overflow when downstream contracts apply + * arithmetic. `10^18` stroops is already far in excess of any plausible + * invoice on Stellar. + */ +const MAX_STROOPS = 10n ** 18n; + +/** + * Reject obviously-bad due dates: not-a-number, non-positive, already in the + * past, or further than 10 years into the future. The 10-year ceiling catches + * accidental "garbage" timestamps (e.g. milliseconds, seconds-since-1970 + * shifted by a stray factor of 1000) without rejecting legitimate financing + * windows. + */ +const MAX_DUE_DATE_HORIZON_MS = 10 * 365 * 24 * 60 * 60 * 1000; + +/** + * Default retry policy for transient RPC failures (network blips, 5xx from + * Soroban RPC). Three attempts with a small exponential backoff (50ms / 100ms + * + uniform jitter up to 50ms) keeps the worst-case latency bounded while + * riding out short-lived outages. + */ +const RPC_RETRY_ATTEMPTS = 3; +const RPC_RETRY_BASE_DELAY_MS = 50; +const RPC_RETRY_MAX_JITTER_MS = 50; + +/** Status returned by {@link InvoiceEscrowContractService.getTransactionStatus}. */ +export type ConfirmationStatus = "SUCCESS" | "FAILED" | "NOT_FOUND"; + export interface InvoiceEscrowContractServiceDependencies { contractId: string; rpcUrl?: string; @@ -37,6 +68,68 @@ export interface InvoiceEscrowContractServiceDependencies { logger?: AppLogger; confirmationPollMs?: number; confirmationAttempts?: number; + /** + * Override for {@link MAX_DUE_DATE_HORIZON_MS} (in milliseconds). Mostly + * useful in tests; leave unset in production. + */ + maxDueDateHorizonMs?: number; + /** + * Override for {@link MAX_STROOPS}. Mostly useful in tests; leave unset in + * production. + */ + maxStroops?: bigint; + /** + * Override for the number of attempts used to ride out transient RPC + * failures inside {@link simulateTransaction} and {@link submitTransaction}. + */ + rpcRetryAttempts?: number; + /** + * Override for the base delay used between RPC retry attempts (ms). + */ + rpcRetryBaseDelayMs?: number; + /** + * When `true`, the build methods and {@link createEscrowOnChain} reject + * `dueDateTimestamp` values that are in the past or further than the + * configured horizon in the future. Defaults to `false` to preserve the + * previous permissive behaviour; new deployments should opt in. + */ + strictDueDateValidation?: boolean; + /** + * Inject the current time (ms since epoch). Used for deterministic testing + * of the strict due-date validator. + */ + now?: () => number; +} + +function sanitizeString(value: unknown, fieldName: string): string { + if (typeof value !== "string") { + throw new ServiceError( + "invalid_input", + `${fieldName} must be a non-empty string.`, + 400, + { field: fieldName, receivedType: typeof value }, + ); + } + const trimmed = value.trim(); + if (!trimmed) { + throw new ServiceError( + "invalid_input", + `${fieldName} is required.`, + 400, + { field: fieldName }, + ); + } + return trimmed; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function jitter(baseMs: number, maxJitterMs: number): number { + // `Math.random` is fine here: jitter is for spreading load, not security. + const jitterMs = Math.floor(Math.random() * maxJitterMs); + return baseMs + jitterMs; } export class InvoiceEscrowContractService { @@ -48,25 +141,31 @@ export class InvoiceEscrowContractService { private readonly logger: AppLogger; private readonly confirmationPollMs: number; private readonly confirmationAttempts: number; + private readonly maxDueDateHorizonMs: number; + private readonly maxStroops: bigint; + private readonly rpcRetryAttempts: number; + private readonly rpcRetryBaseDelayMs: number; + private readonly strictDueDateValidation: boolean; + private readonly now: () => number; constructor( dependenciesOrContractId: string | InvoiceEscrowContractServiceDependencies, logger?: AppLogger, ) { if (typeof dependenciesOrContractId === "string") { - if (!dependenciesOrContractId || !dependenciesOrContractId.trim()) { - throw new Error("contractId is required."); - } - this.contractId = dependenciesOrContractId.trim(); + this.contractId = sanitizeString(dependenciesOrContractId, "contractId"); this.contract = new Contract(this.contractId); this.logger = logger ?? globalLogger; this.confirmationPollMs = 1000; this.confirmationAttempts = 20; + this.maxDueDateHorizonMs = MAX_DUE_DATE_HORIZON_MS; + this.maxStroops = MAX_STROOPS; + this.rpcRetryAttempts = RPC_RETRY_ATTEMPTS; + this.rpcRetryBaseDelayMs = RPC_RETRY_BASE_DELAY_MS; + this.strictDueDateValidation = false; + this.now = () => Date.now(); } else { - if (!dependenciesOrContractId.contractId || !dependenciesOrContractId.contractId.trim()) { - throw new Error("contractId is required."); - } - this.contractId = dependenciesOrContractId.contractId.trim(); + this.contractId = sanitizeString(dependenciesOrContractId.contractId, "contractId"); this.contract = new Contract(this.contractId); this.networkPassphrase = dependenciesOrContractId.networkPassphrase; this.platformSecretKey = dependenciesOrContractId.platformSecretKey; @@ -80,22 +179,114 @@ export class InvoiceEscrowContractService { this.logger = dependenciesOrContractId.logger ?? logger ?? globalLogger; this.confirmationPollMs = dependenciesOrContractId.confirmationPollMs ?? 1000; this.confirmationAttempts = dependenciesOrContractId.confirmationAttempts ?? 20; + this.maxDueDateHorizonMs = + dependenciesOrContractId.maxDueDateHorizonMs ?? MAX_DUE_DATE_HORIZON_MS; + this.maxStroops = dependenciesOrContractId.maxStroops ?? MAX_STROOPS; + this.rpcRetryAttempts = + dependenciesOrContractId.rpcRetryAttempts ?? RPC_RETRY_ATTEMPTS; + this.rpcRetryBaseDelayMs = + dependenciesOrContractId.rpcRetryBaseDelayMs ?? RPC_RETRY_BASE_DELAY_MS; + this.strictDueDateValidation = + dependenciesOrContractId.strictDueDateValidation ?? false; + this.now = dependenciesOrContractId.now ?? (() => Date.now()); } } + /** + * Parse and validate a stroop amount. Throws a sanitized + * {@link ServiceError} (`invalid_input`, 400) on bad input. Accepts + * `bigint`, `number`, or numeric `string`. Numbers must be safe integers; + * strings must parse cleanly via `BigInt`. + */ private parseStroopAmount(amount: bigint | number | string, fieldName = "amountStroops"): bigint { + let parsed: bigint; try { - const parsed = typeof amount === "bigint" ? amount : BigInt(amount); - if (parsed <= 0n) { - throw new Error(`${fieldName} must be positive.`); + if (typeof amount === "bigint") { + parsed = amount; + } else if (typeof amount === "number") { + if (!Number.isFinite(amount) || !Number.isInteger(amount)) { + throw new TypeError("amount is not an integer"); + } + parsed = BigInt(amount); + } else if (typeof amount === "string") { + const trimmed = amount.trim(); + if (!trimmed) { + throw new TypeError("amount is empty"); + } + parsed = BigInt(trimmed); + } else { + throw new TypeError(`unsupported amount type: ${typeof amount}`); } - return parsed; } catch (error) { - if (error instanceof Error && error.message.includes("must be positive")) { - throw error; - } - throw new Error(`Invalid ${fieldName}: ${String(amount)}`); + const reason = error instanceof Error ? error.message : String(error); + throw new ServiceError( + "invalid_input", + `Invalid ${fieldName}: ${reason}`, + 400, + { field: fieldName, reason }, + ); + } + + if (parsed <= 0n) { + throw new ServiceError( + "invalid_input", + `${fieldName} must be positive.`, + 400, + { field: fieldName, value: parsed.toString() }, + ); + } + if (parsed > this.maxStroops) { + throw new ServiceError( + "invalid_input", + `${fieldName} exceeds the maximum allowed value (${this.maxStroops}).`, + 400, + { field: fieldName, value: parsed.toString(), max: this.maxStroops.toString() }, + ); + } + return parsed; + } + + /** + * Validate a future-dated unix timestamp (seconds). The strict checks + * (past date, absurd horizon) only run when + * {@link InvoiceEscrowContractServiceDependencies.strictDueDateValidation} + * is enabled. The shape check (finite, positive) always runs to keep the + * build helpers crash-safe. + */ + private parseDueDate(dueDateTimestamp: number): number { + if (!Number.isFinite(dueDateTimestamp) || dueDateTimestamp <= 0) { + throw new ServiceError( + "invalid_input", + "dueDateTimestamp must be a positive number.", + 400, + { received: dueDateTimestamp }, + ); + } + if (!this.strictDueDateValidation) { + return dueDateTimestamp; + } + const dueMs = dueDateTimestamp * 1000; + const nowMs = this.now(); + if (dueMs <= nowMs) { + throw new ServiceError( + "invalid_input", + "dueDateTimestamp must be in the future.", + 400, + { dueDateTimestamp, nowSeconds: Math.floor(nowMs / 1000) }, + ); } + if (dueMs - nowMs > this.maxDueDateHorizonMs) { + throw new ServiceError( + "invalid_input", + "dueDateTimestamp is further in the future than the allowed horizon.", + 400, + { + dueDateTimestamp, + horizonSeconds: Math.floor(this.maxDueDateHorizonMs / 1000), + }, + ); + } + return dueDateTimestamp; } /** @@ -108,28 +299,19 @@ export class InvoiceEscrowContractService { dueDateTimestamp: number, 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 safeInvoiceId = sanitizeString(invoiceId, "invoiceId"); + const safeSeller = sanitizeString(sellerAddress, "sellerAddress"); + const safeToken = sanitizeString(paymentTokenAddress, "paymentTokenAddress"); const amountBigInt = this.parseStroopAmount(amountStroops, "amountStroops"); + this.parseDueDate(dueDateTimestamp); return this.contract.call( "create_escrow", - nativeToScVal(invoiceId.trim(), { type: "symbol" }), - new Address(sellerAddress.trim()).toScVal(), + nativeToScVal(safeInvoiceId, { type: "symbol" }), + new Address(safeSeller).toScVal(), nativeToScVal(amountBigInt, { type: "i128" }), nativeToScVal(dueDateTimestamp, { type: "u64" }), - new Address(paymentTokenAddress.trim()).toScVal(), + new Address(safeToken).toScVal(), ); } @@ -141,19 +323,14 @@ export class InvoiceEscrowContractService { investorAddress: 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 safeInvoiceId = sanitizeString(invoiceId, "invoiceId"); + const safeInvestor = sanitizeString(investorAddress, "investorAddress"); const amountBigInt = this.parseStroopAmount(amountStroops, "amountStroops"); return this.contract.call( "fund_escrow", - nativeToScVal(invoiceId.trim(), { type: "symbol" }), - new Address(investorAddress.trim()).toScVal(), + nativeToScVal(safeInvoiceId, { type: "symbol" }), + new Address(safeInvestor).toScVal(), nativeToScVal(amountBigInt, { type: "i128" }), ); } @@ -166,19 +343,14 @@ export class InvoiceEscrowContractService { payerAddress: 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 safeInvoiceId = sanitizeString(invoiceId, "invoiceId"); + const safePayer = sanitizeString(payerAddress, "payerAddress"); const amountBigInt = this.parseStroopAmount(amountStroops, "amountStroops"); return this.contract.call( "record_payment", - nativeToScVal(invoiceId.trim(), { type: "symbol" }), - new Address(payerAddress.trim()).toScVal(), + nativeToScVal(safeInvoiceId, { type: "symbol" }), + new Address(safePayer).toScVal(), nativeToScVal(amountBigInt, { type: "i128" }), ); } @@ -187,41 +359,36 @@ 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."); - } + const safeInvoiceId = sanitizeString(invoiceId, "invoiceId"); return this.contract.call( "settle_escrow", - nativeToScVal(invoiceId.trim(), { type: "symbol" }), + nativeToScVal(safeInvoiceId, { type: "symbol" }), ); } /** - * Simulates a transaction against the Soroban RPC endpoint to verify resource limits and auth footprint. + * Simulates a transaction against the Soroban RPC endpoint to verify + * resource limits and auth footprint. Transient RPC failures are retried + * with exponential backoff + jitter before being surfaced as a + * {@link ServiceError}. */ public async simulateTransaction( 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, + "rpc_not_configured", + "Soroban RPC server is not configured for simulation.", + 503, ); } + const simResponse = await this.withRpcRetry( + () => this.rpcServer!.simulateTransaction(transaction), + "simulateTransaction", + ); + const successResponse = simResponse as unknown as { minResourceFee?: string; cost?: { cpuInsns?: string; memBytes?: string }; @@ -246,30 +413,26 @@ export class InvoiceEscrowContractService { } /** - * Submits a transaction to the Stellar network via Soroban RPC sendTransaction. + * Submits a transaction to the Stellar network via Soroban RPC + * `sendTransaction`. Transient RPC failures are retried with exponential + * backoff + jitter before being surfaced as a {@link ServiceError}. */ public async submitTransaction( 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, + "rpc_not_configured", + "Soroban RPC server is not configured for submission.", + 503, ); } + const response = await this.withRpcRetry( + () => this.rpcServer!.sendTransaction(transaction), + "sendTransaction", + ); + return { status: response.status, txHash: response.hash, @@ -278,52 +441,63 @@ export class InvoiceEscrowContractService { } /** - * Polls for transaction confirmation until it reaches SUCCESS, FAILED, or times out. + * Polls for transaction confirmation until it reaches `SUCCESS`, `FAILED`, + * or times out. Each polling cycle tolerates transient RPC errors via + * {@link withRpcRetry}; only `NOT_FOUND` is treated as "keep polling". */ public async waitForTransactionConfirmation( txHash: string, - ): Promise<{ status: "SUCCESS" | "FAILED" | "NOT_FOUND"; ledger: number | null }> { + ): Promise<{ status: ConfirmationStatus; ledger: number | null }> { if (!this.rpcServer) { - throw new Error("Soroban RPC server is not configured for transaction confirmation polling."); - } - if (!txHash || !txHash.trim()) { - throw new Error("txHash is required."); + throw new ServiceError( + "rpc_not_configured", + "Soroban RPC server is not configured for transaction confirmation polling.", + 503, + ); } + const safeTxHash = sanitizeString(txHash, "txHash"); + + let lastLedger: number | null = null; for (let attempt = 0; attempt < this.confirmationAttempts; attempt++) { try { - const result = await this.rpcServer.getTransaction(txHash); - if (result.status === "SUCCESS") { + const result = await this.rpcServer.getTransaction(safeTxHash); + const status = this.extractStatus(result); + if (status === "SUCCESS") { + lastLedger = "ledger" in result ? Number(result.ledger) : null; this.logger.info("Soroban transaction confirmed on-chain.", { - txHash, + txHash: safeTxHash, sorobanContractId: this.contractId, - ledger: "ledger" in result ? Number(result.ledger) : null, + ledger: lastLedger, + attempts: attempt + 1, }); - return { - status: "SUCCESS", - ledger: "ledger" in result ? Number(result.ledger) : null, - }; + return { status: "SUCCESS", ledger: lastLedger }; } - if (result.status === "FAILED") { + if (status === "FAILED") { this.logger.error("Soroban transaction reverted on-chain.", { - txHash, + txHash: safeTxHash, sorobanContractId: this.contractId, + attempts: attempt + 1, }); return { status: "FAILED", ledger: null }; } + // NOT_FOUND: keep polling. } catch (error) { this.logger.warn("Transient error while checking transaction status", { - txHash, + txHash: safeTxHash, attempt: attempt + 1, error: error instanceof Error ? error.message : String(error), }); + // Transient: keep polling. } - await new Promise((resolve) => setTimeout(resolve, this.confirmationPollMs)); + if (attempt < this.confirmationAttempts - 1) { + await sleep(this.confirmationPollMs); + } } this.logger.error("Timed out waiting for transaction confirmation.", { - txHash, + txHash: safeTxHash, sorobanContractId: this.contractId, attempts: this.confirmationAttempts, }); @@ -331,18 +505,104 @@ export class InvoiceEscrowContractService { "transaction_confirmation_timeout", "Timed out waiting for transaction confirmation on-chain.", 504, + { + txHash: safeTxHash, + attempts: this.confirmationAttempts, + pollMs: this.confirmationPollMs, + lastLedger, + }, ); } /** - * Creates/initializes an escrow on-chain and logs the structured completion event. - * Ensures that only sanitized metadata (invoiceId, sorobanContractId, sellerAddress, amountStroops) - * is logged without leaking any secret keys, signing seeds, or auth tokens. + * Extract a normalized status from the heterogeneous response shapes + * returned by different Soroban RPC versions. + */ + private extractStatus( + result: Awaited>, + ): ConfirmationStatus { + const raw = (result as { status?: unknown }).status; + if (raw === "SUCCESS") return "SUCCESS"; + if (raw === "FAILED") return "FAILED"; + return "NOT_FOUND"; + } + + /** + * Run an RPC call with bounded retry on transient failures. The full failure + * is logged and wrapped in a {@link ServiceError} (`502`) once retries are + * exhausted so callers see a stable, sanitized error code. The final + * `error`-level log preserves the legacy messages ("Soroban simulateTransac + * tion call failed." / "Soroban sendTransaction call failed.") so existing + * log-based alerting keeps working. + */ + private async withRpcRetry( + operation: () => Promise, + operationName: string, + ): Promise { + let lastError: unknown; + const finalFailureMessage = + operationName === "simulateTransaction" + ? "Soroban simulateTransaction call failed." + : "Soroban sendTransaction call failed."; + const finalErrorCode = + operationName === "simulateTransaction" + ? "soroban_simulation_failed" + : "soroban_submission_failed"; + const finalErrorDescription = + operationName === "simulateTransaction" + ? "Failed to simulate the transaction against the Soroban RPC endpoint." + : "Failed to submit the transaction to the Soroban RPC endpoint."; + + for (let attempt = 1; attempt <= this.rpcRetryAttempts; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + const isLast = attempt === this.rpcRetryAttempts; + if (isLast) { + this.logger.error(finalFailureMessage, { + sorobanContractId: this.contractId, + operation: operationName, + attempts: attempt, + error: error instanceof Error ? error.message : String(error), + }); + break; + } + this.logger.warn("Soroban RPC call failed, will retry if attempts remain", { + operation: operationName, + attempt, + attemptsRemaining: this.rpcRetryAttempts - attempt, + error: error instanceof Error ? error.message : String(error), + }); + const delay = jitter(this.rpcRetryBaseDelayMs * 2 ** (attempt - 1), RPC_RETRY_MAX_JITTER_MS); + await sleep(delay); + } + } + + const reason = lastError instanceof Error ? lastError.message : String(lastError); + throw new ServiceError(finalErrorCode, finalErrorDescription, 502, { + operation: operationName, + attempts: this.rpcRetryAttempts, + reason, + }); + } + + /** + * Creates/initializes an escrow on-chain and logs the structured completion + * event. + * + * Note: this method builds the operation payload and emits the structured + * log line; actual on-chain submission is performed by the caller using + * {@link submitTransaction} + {@link waitForTransactionConfirmation}. + * Only sanitized metadata (`invoiceId`, `sorobanContractId`, + * `sellerAddress`, `amountStroops`) is logged — no secret keys, signing + * seeds, or auth tokens are ever written to logs. */ public async createEscrowOnChain( input: CreateEscrowInput, ): Promise { const amountBigInt = this.parseStroopAmount(input.amountStroops, "amountStroops"); + this.parseDueDate(input.dueDateTimestamp); const operation = this.buildCreateEscrowTx( input.invoiceId, @@ -354,7 +614,6 @@ export class InvoiceEscrowContractService { const amountStroopsStr = amountBigInt.toString(); - // Log structured event on successful escrow creation this.logger.info("Soroban escrow created successfully on-chain.", { invoiceId: input.invoiceId, sorobanContractId: this.contractId, diff --git a/tests/unit/auth.service.hardening.test.ts b/tests/unit/auth.service.hardening.test.ts new file mode 100644 index 0000000..06a88bc --- /dev/null +++ b/tests/unit/auth.service.hardening.test.ts @@ -0,0 +1,375 @@ +import crypto from "crypto"; +import { Keypair, Networks } from "stellar-sdk"; +import { AuthService } from "@/services/auth.service"; +import type { + ChallengeRepositoryContract, + UserRepositoryContract, +} from "@/services/auth.service"; +import type { AppLogger, LogMetadata } from "@/observability/logger"; +import { KYCStatus, UserType } from "@/types/enums"; +import { User } from "@/models/User.model"; + +interface InMemoryChallenge { + id: string; + stellarAddress: string; + nonceHash: string; + message: string; + network: string; + issuedAt: Date; + expiresAt: Date; + consumedAt: Date | null; +} + +type InMemoryUser = User; + +class InMemoryUserRepository implements UserRepositoryContract { + private readonly users = new Map(); + + async findById(id: string) { + return this.users.get(id) ?? null; + } + + async findByStellarAddress(stellarAddress: string) { + return ( + [...this.users.values()].find((u) => u.stellarAddress === stellarAddress) ?? null + ); + } + + async save(user: Partial): Promise { + const now = new Date(); + const entity: InMemoryUser = { + id: crypto.randomUUID(), + stellarAddress: user.stellarAddress ?? "", + email: user.email ?? null, + userType: user.userType ?? UserType.INVESTOR, + kycStatus: user.kycStatus ?? KYCStatus.PENDING, + isKycVerified: user.isKycVerified ?? false, + createdAt: user.createdAt ?? now, + updatedAt: user.updatedAt ?? now, + deletedAt: user.deletedAt ?? null, + invoices: user.invoices ?? [], + investments: user.investments ?? [], + transactions: user.transactions ?? [], + kycVerifications: user.kycVerifications ?? [], + notifications: user.notifications ?? [], + }; + this.users.set(entity.id, entity); + return entity; + } +} + +class InMemoryChallengeRepository implements ChallengeRepositoryContract { + readonly challenges = new Map(); + public saveCount = 0; + + async create(input: InMemoryChallenge): Promise { + const challenge: InMemoryChallenge = { + ...input, + id: crypto.randomUUID(), + consumedAt: null, + }; + this.challenges.set(challenge.id, challenge); + return challenge; + } + + async findByAddressAndNonceHash(stellarAddress: string, nonceHash: string) { + return ( + [...this.challenges.values()].find( + (c) => c.stellarAddress === stellarAddress && c.nonceHash === nonceHash, + ) ?? null + ); + } + + async consume(id: string, consumedAt: Date) { + const challenge = this.challenges.get(id); + if (!challenge || challenge.consumedAt) return false; + challenge.consumedAt = consumedAt; + return true; + } +} + +interface LogEntry { + level: "debug" | "info" | "warn" | "error"; + message: string; + metadata: LogMetadata; +} + +class CaptureLogger implements AppLogger { + constructor(readonly entries: LogEntry[] = []) {} + + debug(message: string, metadata: LogMetadata = {}): void { + this.entries.push({ level: "debug", message, metadata }); + } + info(message: string, metadata: LogMetadata = {}): void { + this.entries.push({ level: "info", message, metadata }); + } + warn(message: string, metadata: LogMetadata = {}): void { + this.entries.push({ level: "warn", message, metadata }); + } + error(message: string, metadata: LogMetadata = {}): void { + this.entries.push({ level: "error", message, metadata }); + } + child(_metadata: LogMetadata): AppLogger { + return new CaptureLogger(this.entries); + } +} + +interface TestHarness { + service: AuthService; + userRepository: InMemoryUserRepository; + challengeRepository: InMemoryChallengeRepository; + logger: CaptureLogger; +} + +function createHarness(overrides: Partial<{ now: () => number }> = {}): TestHarness { + const userRepository = new InMemoryUserRepository(); + const challengeRepository = new InMemoryChallengeRepository(); + const logger = new CaptureLogger(); + const service = new AuthService({ + userRepository, + challengeRepository, + config: { + jwt: { secret: "test-secret-hardening", expiresIn: "15m" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + }, + logger, + ...(overrides.now ? { now: overrides.now } : {}), + }); + return { service, userRepository, challengeRepository, logger }; +} + +async function completeAuthFlow( + service: AuthService, + keypair: Keypair, + logger?: CaptureLogger, +): Promise<{ token: string; user: { stellarAddress: string } }> { + const challenge = await service.createChallenge(keypair.publicKey()); + const signature = keypair.sign(Buffer.from(challenge.message, "utf8")).toString("base64"); + const verified = await service.verifyChallenge({ + publicKey: keypair.publicKey(), + nonce: challenge.nonce, + signature, + ipAddress: "198.51.100.7", + }); + // Mark logger as used so the parameter stays in the signature for future + // debugging hookups without triggering an unused-var lint error. + void logger; + return verified; +} + +describe("AuthService - hardening", () => { + describe("createChallenge input sanitization", () => { + it("rejects non-string publicKey without throwing a TypeError", async () => { + const { service, logger } = createHarness(); + // Casting via `any` is intentional: we are exercising the runtime safety + // net that catches non-string input before it reaches `assertValidPublicKey`. + const unsafe = service as unknown as { createChallenge(input: unknown): Promise }; + await expect(unsafe.createChallenge(undefined)).rejects.toMatchObject({ + statusCode: 400, + message: "Invalid Stellar public key.", + }); + const invalidKeyLogs = logger.entries.filter((e) => e.message === "auth.invalid_public_key"); + expect(invalidKeyLogs.length).toBeGreaterThan(0); + }); + + it("rejects non-string publicKey even when it's an empty string after coercion", async () => { + const { service } = createHarness(); + const unsafe = service as unknown as { createChallenge(input: unknown): Promise }; + await expect(unsafe.createChallenge(123)).rejects.toMatchObject({ + statusCode: 400, + }); + }); + + it("trims surrounding whitespace before validation", async () => { + const { service, challengeRepository } = createHarness(); + const keypair = Keypair.random(); + const challenge = await service.createChallenge(` ${keypair.publicKey()} `); + expect(challenge.publicKey).toBe(keypair.publicKey()); + expect(challenge.publicKey).not.toMatch(/^\s|\s$/); + const stored = [...challengeRepository.challenges.values()][0]; + expect(stored.stellarAddress).toBe(keypair.publicKey()); + }); + }); + + describe("createChallenge structured logging", () => { + it("emits an auth.challenge_created info log on success", async () => { + const { service, logger } = createHarness(); + const keypair = Keypair.random(); + await service.createChallenge(keypair.publicKey()); + + const createdLog = logger.entries.find( + (e) => e.level === "info" && e.message === "auth.challenge_created", + ); + expect(createdLog).toBeDefined(); + expect(createdLog!.metadata.wallet).toBe(keypair.publicKey()); + expect(createdLog!.metadata.network).toBe("testnet"); + expect(typeof createdLog!.metadata.issued_at).toBe("string"); + expect(typeof createdLog!.metadata.expires_at).toBe("string"); + }); + }); + + describe("verifyChallenge structured logging", () => { + it("emits an auth.user_upserted info log on first-time login", async () => { + const { service, logger } = createHarness(); + const keypair = Keypair.random(); + await completeAuthFlow(service, keypair, logger); + const upsertLog = logger.entries.find( + (e) => e.level === "info" && e.message === "auth.user_upserted", + ); + expect(upsertLog).toBeDefined(); + expect(upsertLog!.metadata.wallet).toBe(keypair.publicKey()); + }); + + it("does NOT emit auth.user_upserted on repeat login for an existing user", async () => { + const { service, logger } = createHarness(); + const keypair = Keypair.random(); + await completeAuthFlow(service, keypair, logger); + const upsertCountAfterFirst = logger.entries.filter( + (e) => e.message === "auth.user_upserted", + ).length; + expect(upsertCountAfterFirst).toBe(1); + + await completeAuthFlow(service, keypair, logger); + const upsertCountAfterSecond = logger.entries.filter( + (e) => e.message === "auth.user_upserted", + ).length; + expect(upsertCountAfterSecond).toBe(1); + }); + + it("emits auth.verify_failed with a truncated wallet on bad signature", async () => { + const { service, logger } = createHarness(); + const keypair = Keypair.random(); + await service.createChallenge(keypair.publicKey()); + + await expect( + service.verifyChallenge({ + publicKey: keypair.publicKey(), + nonce: "a".repeat(32), + signature: "aW52YWxpZA==", + }), + ).rejects.toMatchObject({ statusCode: 401 }); + + const failed = logger.entries.find( + (e) => e.level === "info" && e.message === "auth.verify_failed", + ); + expect(failed).toBeDefined(); + expect(failed!.metadata.wallet).toMatch(/^.{4}\.{3}.{4}$/); + }); + + it("never logs the raw signature or nonce on failure paths", async () => { + const { service, logger } = createHarness(); + const keypair = Keypair.random(); + await service.createChallenge(keypair.publicKey()); + + const rawNonce = "abcdef0123456789abcdef0123456789"; + const rawSignature = "aW52YWxpZA=="; + + await expect( + service.verifyChallenge({ + publicKey: keypair.publicKey(), + nonce: rawNonce, + signature: rawSignature, + }), + ).rejects.toBeDefined(); + + for (const entry of logger.entries) { + const serialized = JSON.stringify(entry); + expect(serialized).not.toContain(rawSignature); + expect(serialized).not.toContain(rawNonce); + } + }); + }); + + describe("verifyChallenge single-flight user upsert", () => { + it("coalesces concurrent first-time logins for the same wallet into a single repository save", async () => { + const { service, userRepository, challengeRepository } = createHarness(); + + // Pre-create two distinct challenges so both verify paths run. + const keypair = Keypair.random(); + const [c1, c2] = await Promise.all([ + service.createChallenge(keypair.publicKey()), + service.createChallenge(keypair.publicKey()), + ]); + + const sig1 = keypair.sign(Buffer.from(c1.message, "utf8")).toString("base64"); + const sig2 = keypair.sign(Buffer.from(c2.message, "utf8")).toString("base64"); + + const [r1, r2] = await Promise.all([ + service.verifyChallenge({ publicKey: keypair.publicKey(), nonce: c1.nonce, signature: sig1 }), + service.verifyChallenge({ publicKey: keypair.publicKey(), nonce: c2.nonce, signature: sig2 }), + ]); + + // Both calls succeed (different challenges). + expect(r1.token).toBeDefined(); + expect(r2.token).toBeDefined(); + + // Only one user row exists in the repository (single-flight coalesced + // the two upserts onto a single `save` call). + const users = [...userRepository["users"].values()] as User[]; + expect(users).toHaveLength(1); + expect(users[0].stellarAddress).toBe(keypair.publicKey()); + + // Both challenges were consumed. + const consumed = [...challengeRepository.challenges.values()].filter( + (c) => c.consumedAt !== null, + ); + expect(consumed).toHaveLength(2); + }); + }); + + describe("verifyChallenge input sanitization", () => { + it("rejects non-string publicKey, nonce, and signature", async () => { + const { service } = createHarness(); + const keypair = Keypair.random(); + const unsafe = service as unknown as { + verifyChallenge(input: { publicKey: unknown; nonce: unknown; signature: unknown }): Promise; + }; + + await expect( + unsafe.verifyChallenge({ publicKey: null, nonce: "a".repeat(32), signature: "sig" }), + ).rejects.toMatchObject({ statusCode: 400 }); + + await expect( + unsafe.verifyChallenge({ publicKey: keypair.publicKey(), nonce: undefined, signature: "sig" }), + ).rejects.toMatchObject({ statusCode: 400 }); + + await expect( + unsafe.verifyChallenge({ publicKey: keypair.publicKey(), nonce: "a".repeat(32), signature: undefined }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe("getCurrentUser hardening", () => { + it("rejects a non-string token without throwing a TypeError", async () => { + const { service } = createHarness(); + const unsafe = service as unknown as { getCurrentUser(input: unknown): Promise }; + await expect(unsafe.getCurrentUser(undefined)).rejects.toMatchObject({ statusCode: 401 }); + await expect(unsafe.getCurrentUser(null)).rejects.toMatchObject({ statusCode: 401 }); + await expect(unsafe.getCurrentUser(123)).rejects.toMatchObject({ statusCode: 401 }); + }); + + it("wraps unexpected errors in HttpError(500) without leaking internals", async () => { + const { service, userRepository } = createHarness(); + const keypair = Keypair.random(); + // Sign a token with the same secret the service uses. + const crypto = await import("crypto"); + const jwt = await import("jsonwebtoken"); + const token = jwt.default.sign( + { sub: keypair.publicKey() }, + "test-secret-hardening", + { expiresIn: "1h" }, + ); + + const original = userRepository.findByStellarAddress.bind(userRepository); + userRepository.findByStellarAddress = async () => { + throw new Error(`internal db boom ${crypto.randomUUID()}`); + }; + try { + await expect(service.getCurrentUser(token)).rejects.toMatchObject({ statusCode: 500 }); + } finally { + userRepository.findByStellarAddress = original; + } + }); + }); +}); diff --git a/tests/unit/config-data-source.test.ts b/tests/unit/config-data-source.test.ts new file mode 100644 index 0000000..4aee271 --- /dev/null +++ b/tests/unit/config-data-source.test.ts @@ -0,0 +1,219 @@ +type LoggerMock = { + info: jest.Mock; + warn: jest.Mock; + error: jest.Mock; + debug: jest.Mock; +}; + +function createLoggerMock(): LoggerMock { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }; +} + +function createDataSourceMock(overrides: Partial<{ + isInitialized: boolean; + initialize: jest.Mock; + destroy: jest.Mock; + options: Record; +}> = {}) { + return { + isInitialized: false, + initialize: jest.fn(async () => { + /* default succeeds */ + }), + destroy: jest.fn(async () => { + /* default succeeds */ + }), + options: { type: "sqlite", migrationsRun: false }, + ...overrides, + }; +} + +describe("data-source module", () => { + const ORIGINAL_ENV = process.env; + + let loggerMock: LoggerMock; + let DataSourceMock: jest.Mock; + let dataSourceInstance: ReturnType; + let loggerModuleId: string; + let dataSourceModuleId: string; + + async function loadFreshModule(): Promise<{ + dataSource: typeof dataSourceInstance; + mod: typeof import("../../src/config/data-source"); + }> { + jest.resetModules(); + jest.doMock("../../src/observability/logger", () => ({ + logger: loggerMock, + })); + jest.doMock("../../src/config/database", () => { + DataSourceMock(dataSourceInstance); + return { default: dataSourceInstance, __esModule: true }; + }); + const mod = await import("../../src/config/data-source"); + return { dataSource: dataSourceInstance, mod }; + } + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV, NODE_ENV: "test" }; + loggerMock = createLoggerMock(); + DataSourceMock = jest.fn(); + dataSourceInstance = createDataSourceMock(); + loggerModuleId = "../../src/observability/logger"; + dataSourceModuleId = "../../src/config/database"; + }); + + afterEach(() => { + jest.resetModules(); + jest.dontMock(loggerModuleId); + jest.dontMock(dataSourceModuleId); + process.env = ORIGINAL_ENV; + }); + + it("throws an AppError when the underlying DataSource is missing", async () => { + jest.resetModules(); + jest.doMock("../../src/observability/logger", () => ({ logger: loggerMock })); + jest.doMock("../../src/config/database", () => ({ default: undefined, __esModule: true })); + + await expect(import("../../src/config/data-source")).rejects.toMatchObject({ + name: "AppError", + code: "DATASOURCE_INVALID", + statusCode: 500, + }); + }); + + it("throws an AppError when the DataSource lacks required TypeORM methods", async () => { + jest.resetModules(); + jest.doMock("../../src/observability/logger", () => ({ logger: loggerMock })); + jest.doMock("../../src/config/database", () => ({ default: { foo: "bar" }, __esModule: true })); + + await expect(import("../../src/config/data-source")).rejects.toMatchObject({ + code: "DATASOURCE_SHAPE_INVALID", + }); + }); + + it("initializes the DataSource exactly once across concurrent callers", async () => { + let resolveInit: () => void = () => { + throw new Error("resolveInit called before assignment"); + }; + dataSourceInstance.initialize = jest.fn( + () => + new Promise((resolve) => { + resolveInit = () => { + dataSourceInstance.isInitialized = true; + resolve(); + }; + }), + ); + + const { mod } = await loadFreshModule(); + + const a = mod.initializeDataSource(); + const b = mod.initializeDataSource(); + const c = mod.initializeDataSource(); + + expect(dataSourceInstance.initialize).toHaveBeenCalledTimes(1); + + resolveInit(); + const results = await Promise.all([a, b, c]); + + expect(results).toEqual([dataSourceInstance, dataSourceInstance, dataSourceInstance]); + expect(dataSourceInstance.initialize).toHaveBeenCalledTimes(1); + expect(loggerMock.info).toHaveBeenCalledWith( + "DataSource initialized successfully", + expect.objectContaining({ driver: "sqlite", duration_ms: expect.any(Number) }), + ); + }); + + it("returns the existing DataSource without reinitializing when already open", async () => { + dataSourceInstance.isInitialized = true; + const { mod } = await loadFreshModule(); + + const result = await mod.initializeDataSource(); + + expect(result).toBe(dataSourceInstance); + expect(dataSourceInstance.initialize).not.toHaveBeenCalled(); + expect(loggerMock.debug).toHaveBeenCalledWith( + "DataSource already initialized, reusing connection", + expect.any(Object), + ); + }); + + it("wraps initialization failures in an AppError and clears the cached promise", async () => { + const failure = new Error("ECONNREFUSED"); + dataSourceInstance.initialize = jest.fn(async () => { + throw failure; + }); + + const { mod } = await loadFreshModule(); + + await expect(mod.initializeDataSource()).rejects.toMatchObject({ code: "DATASOURCE_INIT_FAILED" }); + await expect(mod.initializeDataSource()).rejects.toMatchObject({ code: "DATASOURCE_INIT_FAILED" }); + + expect(dataSourceInstance.initialize).toHaveBeenCalledTimes(2); + expect(loggerMock.error).toHaveBeenCalledWith( + "Failed to initialize DataSource", + expect.objectContaining({ error: "ECONNREFUSED" }), + ); + }); + + it("closes an open DataSource and resets the initialization cache", async () => { + dataSourceInstance.isInitialized = true; + const { mod } = await loadFreshModule(); + + await mod.closeDataSource(); + + expect(dataSourceInstance.destroy).toHaveBeenCalledTimes(1); + expect(loggerMock.info).toHaveBeenCalledWith( + "DataSource closed successfully", + expect.any(Object), + ); + + dataSourceInstance.isInitialized = false; + await mod.initializeDataSource(); + expect(dataSourceInstance.initialize).toHaveBeenCalledTimes(1); + }); + + it("tolerates closing a DataSource that was never initialized", async () => { + const { mod } = await loadFreshModule(); + await expect(mod.closeDataSource()).resolves.toBeUndefined(); + expect(dataSourceInstance.destroy).not.toHaveBeenCalled(); + }); + + it("wraps shutdown failures in an AppError", async () => { + dataSourceInstance.isInitialized = true; + dataSourceInstance.destroy = jest.fn(async () => { + throw new Error("pool closed"); + }); + + const { mod } = await loadFreshModule(); + + await expect(mod.closeDataSource()).rejects.toMatchObject({ + code: "DATASOURCE_SHUTDOWN_FAILED", + }); + expect(loggerMock.error).toHaveBeenCalledWith( + "Failed to close DataSource", + expect.objectContaining({ error: "pool closed" }), + ); + }); + + it("reports readiness via isDataSourceReady without initializing", async () => { + dataSourceInstance.isInitialized = false; + const { mod } = await loadFreshModule(); + + expect(mod.isDataSourceReady()).toBe(false); + + dataSourceInstance.isInitialized = true; + expect(mod.isDataSourceReady()).toBe(true); + expect(dataSourceInstance.initialize).not.toHaveBeenCalled(); + }); + + it("re-exports the shared DataSource as default for the TypeORM CLI", async () => { + const { mod } = await loadFreshModule(); + expect(mod.default).toBe(dataSourceInstance); + }); +}); diff --git a/tests/unit/services/stellar/invoice-escrow-contract.service.hardening.test.ts b/tests/unit/services/stellar/invoice-escrow-contract.service.hardening.test.ts new file mode 100644 index 0000000..3e62536 --- /dev/null +++ b/tests/unit/services/stellar/invoice-escrow-contract.service.hardening.test.ts @@ -0,0 +1,342 @@ +import { ServiceError } from "../../../../src/utils/service-error"; +import { InvoiceEscrowContractService } from "../../../../src/services/stellar/invoice-escrow-contract.service"; +import type { AppLogger } from "../../../../src/observability/logger"; + +describe("InvoiceEscrowContractService - hardening", () => { + const ESCROW_CONTRACT_ID = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + const TEST_SELLER = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + const TEST_TOKEN = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + const TEST_INVOICE_ID = "INV-2026-001"; + const TEST_AMOUNT_STROOPS = 500_000_000n; + // Future-safe due date: year ~2030. + const FUTURE_DUE_DATE = 1893456000; + const FUTURE_MS = FUTURE_DUE_DATE * 1000; + + let mockLogger: AppLogger; + + beforeEach(() => { + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: jest.fn().mockReturnThis(), + }; + }); + + describe("parseStroopAmount hardening", () => { + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + }); + + it.each([ + ["empty string", ""], + ["whitespace string", " "], + ["non-numeric string", "abc"], + ["decimal string", "1.5"], + ])("rejects %s as ServiceError(invalid_input)", (_label, value) => { + expect(() => + service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, value, FUTURE_DUE_DATE, TEST_TOKEN), + ).toThrow(ServiceError); + }); + + it("rejects non-integer numbers", () => { + expect(() => + service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, 1.5, FUTURE_DUE_DATE, TEST_TOKEN), + ).toThrow(ServiceError); + expect(() => + service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, NaN, FUTURE_DUE_DATE, TEST_TOKEN), + ).toThrow(ServiceError); + }); + + it("rejects amounts above the configured ceiling", () => { + const tinyCapService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + maxStroops: 1000n, + }); + try { + tinyCapService.buildCreateEscrowTx( + TEST_INVOICE_ID, + TEST_SELLER, + 1001n, + FUTURE_DUE_DATE, + TEST_TOKEN, + ); + throw new Error("expected throw"); + } catch (error) { + expect(error).toBeInstanceOf(ServiceError); + expect((error as ServiceError).code).toBe("invalid_input"); + expect((error as ServiceError).statusCode).toBe(400); + } + }); + + it("sanitizes the underlying error message before surfacing it", () => { + try { + service.buildCreateEscrowTx( + TEST_INVOICE_ID, + TEST_SELLER, + "not-a-number", + FUTURE_DUE_DATE, + TEST_TOKEN, + ); + throw new Error("expected throw"); + } catch (error) { + expect(error).toBeInstanceOf(ServiceError); + const message = (error as Error).message; + // The original `SyntaxError` from `BigInt(...)` leaks no internal + // details — only the sanitized field-prefixed message should be + // visible to the caller. + expect(message.startsWith("Invalid amountStroops:")).toBe(true); + } + }); + }); + + describe("strict due-date validation (opt-in)", () => { + it("rejects past due dates when strict validation is enabled", () => { + const pastService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + strictDueDateValidation: true, + now: () => FUTURE_MS + 60_000, + }); + try { + pastService.buildCreateEscrowTx( + TEST_INVOICE_ID, + TEST_SELLER, + TEST_AMOUNT_STROOPS, + FUTURE_DUE_DATE, + TEST_TOKEN, + ); + throw new Error("expected throw"); + } catch (error) { + expect(error).toBeInstanceOf(ServiceError); + expect((error as ServiceError).code).toBe("invalid_input"); + expect((error as Error).message).toBe("dueDateTimestamp must be in the future."); + } + }); + + it("rejects due dates far in the future when strict validation is enabled", () => { + const farFuture = FUTURE_MS / 1000 + 100 * 365 * 24 * 60 * 60; + const farService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + strictDueDateValidation: true, + now: () => FUTURE_MS, + }); + expect(() => + farService.buildCreateEscrowTx( + TEST_INVOICE_ID, + TEST_SELLER, + TEST_AMOUNT_STROOPS, + farFuture, + TEST_TOKEN, + ), + ).toThrow(ServiceError); + }); + + it("still rejects malformed due dates even without strict validation", () => { + const permissiveService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + }); + expect(() => + permissiveService.buildCreateEscrowTx( + TEST_INVOICE_ID, + TEST_SELLER, + TEST_AMOUNT_STROOPS, + 0, + TEST_TOKEN, + ), + ).toThrow(/dueDateTimestamp must be a positive number/); + expect(() => + permissiveService.buildCreateEscrowTx( + TEST_INVOICE_ID, + TEST_SELLER, + TEST_AMOUNT_STROOPS, + Number.NaN, + TEST_TOKEN, + ), + ).toThrow(/dueDateTimestamp must be a positive number/); + }); + + it("accepts past due dates when strict validation is disabled (back-compat)", () => { + const permissiveService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + }); + expect(() => + permissiveService.buildCreateEscrowTx( + TEST_INVOICE_ID, + TEST_SELLER, + TEST_AMOUNT_STROOPS, + 1000, + TEST_TOKEN, + ), + ).not.toThrow(); + }); + }); + + describe("RPC retry/backoff", () => { + it("recovers from a transient simulateTransaction failure on retry", async () => { + const mockServer = { + simulateTransaction: jest + .fn, []>() + .mockRejectedValueOnce(new Error("ECONNRESET")) + .mockResolvedValueOnce({ minResourceFee: "42", cost: { cpuInsns: "1", memBytes: "2" } }), + } as any; + + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + server: mockServer, + logger: mockLogger, + rpcRetryBaseDelayMs: 1, + }); + + const result = await service.simulateTransaction({} as any); + + expect(result.minResourceFee).toBe("42"); + expect(mockServer.simulateTransaction).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Soroban RPC call failed, will retry if attempts remain", + expect.objectContaining({ operation: "simulateTransaction", attempt: 1 }), + ); + }); + + it("wraps a persistent simulateTransaction failure in ServiceError 502 after retries", async () => { + const mockServer = { + simulateTransaction: jest.fn().mockRejectedValue(new Error("ECONNRESET")), + } as any; + + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + server: mockServer, + logger: mockLogger, + rpcRetryAttempts: 3, + rpcRetryBaseDelayMs: 1, + }); + + await expect(service.simulateTransaction({} as any)).rejects.toMatchObject({ + code: "soroban_simulation_failed", + statusCode: 502, + }); + expect(mockServer.simulateTransaction).toHaveBeenCalledTimes(3); + expect(mockLogger.error).toHaveBeenCalledWith( + "Soroban simulateTransaction call failed.", + expect.objectContaining({ sorobanContractId: ESCROW_CONTRACT_ID }), + ); + }); + + it("recovers from a transient submitTransaction failure on retry", async () => { + const mockServer = { + sendTransaction: jest + .fn, []>() + .mockRejectedValueOnce(new Error("timeout")) + .mockResolvedValueOnce({ status: "PENDING", hash: "h" }), + } as any; + + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + server: mockServer, + logger: mockLogger, + rpcRetryBaseDelayMs: 1, + }); + + const result = await service.submitTransaction({} as any); + expect(result.status).toBe("PENDING"); + expect(mockServer.sendTransaction).toHaveBeenCalledTimes(2); + }); + + it("honours a custom rpcRetryAttempts override", async () => { + const mockServer = { + sendTransaction: jest.fn().mockRejectedValue(new Error("boom")), + } as any; + + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + server: mockServer, + logger: mockLogger, + rpcRetryAttempts: 5, + rpcRetryBaseDelayMs: 1, + }); + + await expect(service.submitTransaction({} as any)).rejects.toBeInstanceOf(ServiceError); + expect(mockServer.sendTransaction).toHaveBeenCalledTimes(5); + }); + }); + + describe("RPC not configured", () => { + it("throws ServiceError(503) for simulateTransaction without server", async () => { + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + }); + await expect(service.simulateTransaction({} as any)).rejects.toMatchObject({ + code: "rpc_not_configured", + statusCode: 503, + }); + }); + + it("throws ServiceError(503) for submitTransaction without server", async () => { + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + }); + await expect(service.submitTransaction({} as any)).rejects.toMatchObject({ + code: "rpc_not_configured", + statusCode: 503, + }); + }); + + it("throws ServiceError(503) for waitForTransactionConfirmation without server", async () => { + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + }); + await expect(service.waitForTransactionConfirmation("hash")).rejects.toMatchObject({ + code: "rpc_not_configured", + statusCode: 503, + }); + }); + }); + + describe("sanitization", () => { + it("rejects non-string invoiceId in build helpers as ServiceError", () => { + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + }); + // @ts-expect-error - exercising runtime safety + expect(() => service.buildFundEscrowTx(undefined, TEST_SELLER, TEST_AMOUNT_STROOPS)).toThrow( + ServiceError, + ); + }); + + it("does not log platformSecretKey in createEscrowOnChain", async () => { + const secret = "SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + const service = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + logger: mockLogger, + platformSecretKey: secret, + }); + await service.createEscrowOnChain({ + invoiceId: TEST_INVOICE_ID, + sellerAddress: TEST_SELLER, + amountStroops: TEST_AMOUNT_STROOPS, + dueDateTimestamp: FUTURE_DUE_DATE, + paymentTokenAddress: TEST_TOKEN, + }); + const allCalls = [ + ...(mockLogger.info as jest.Mock).mock.calls, + ...(mockLogger.warn as jest.Mock).mock.calls, + ...(mockLogger.error as jest.Mock).mock.calls, + ...(mockLogger.debug as jest.Mock).mock.calls, + ]; + for (const call of allCalls) { + const serialized = JSON.stringify(call); + expect(serialized).not.toContain(secret); + } + }); + }); +});