From 096f6f5a616d0cb98f7046c59c7728a20b8b954f Mon Sep 17 00:00:00 2001 From: dee-john Date: Fri, 16 Oct 2026 12:31:48 +0100 Subject: [PATCH 1/3] refactor(auth): optimize auth service with caching and concurrency --- src/services/auth.service.ts | 418 +++++++++--- src/utils/http-error.ts | 21 +- .../settlement.integration.test.ts | 4 - tests/invoice.service.test.ts | 91 ++- .../auth.service.optimization.test.ts | 599 ++++++++++++++++++ 5 files changed, 1006 insertions(+), 127 deletions(-) create mode 100644 tests/unit/services/auth.service.optimization.test.ts diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 735e458c..6454ba40 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -6,11 +6,8 @@ import type { AppConfig } from "../config/env"; import { AuthChallenge } from "../models/AuthChallenge.model"; import { User, USER_PROFILE_SELECT } from "../models/User.model"; import type { PublicUser } from "../types/auth"; -import { HttpError } from "../utils/http-error"; -import { - buildAuthFailureDetails, - classifyJwtError, -} from "../lib/auth-failure"; +import { AppError, HttpError } from "../utils/http-error"; +import { buildAuthFailureDetails, classifyJwtError } from "../lib/auth-failure"; import type { AppLogger } from "../observability/logger"; import { buildWalletChallenge } from "../utils/stellar-challenge"; import { MetricsRegistry } from "../observability/metrics"; @@ -69,6 +66,16 @@ export interface AuthConfig extends Pick { jwt: AppConfig["jwt"] & { publicKey?: string }; serverKeypair?: Keypair; serverPublicKey?: string; + /** + * Optional TTL (ms) for caching public user profile lookups. + * Defaults to 0 (in-flight coalescing active, no stale cache across distinct turns unless configured). + */ + userCacheTtlMs?: number; + /** + * Optional TTL (ms) for caching challenge status metrics. + * Defaults to 5,000ms to throttle heavy metrics scraper requests. + */ + metricsCacheTtlMs?: number; } export interface AuthServiceDependencies { @@ -112,6 +119,97 @@ export interface VerifyChallengeResponse { /** Minimum nonce length (chars) accepted by verifyChallenge. */ const MIN_NONCE_LENGTH = 16; +/** Maximum nonce length (chars) accepted to prevent excessive payload DOS. */ +const MAX_NONCE_LENGTH = 512; +/** Maximum signature length (chars) accepted. */ +const MAX_SIGNATURE_LENGTH = 2048; +/** Maximum bearer token length to guard against parser CPU exhaustion. */ +const MAX_TOKEN_LENGTH = 8192; + +/** + * Lightweight, bounded LRU cache with time-to-live (TTL) expiration. + * Protects downstream databases from query exhaustion under high concurrent load. + */ +export class LRUCache { + private readonly map = new Map(); + + constructor( + private readonly maxSize: number = 1000, + private readonly defaultTtlMs: number = 10_000 + ) { + if (maxSize <= 0) { + throw new Error("LRUCache maxSize must be greater than zero"); + } + } + + get(key: K): V | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + + if (Date.now() > entry.expiresAt) { + this.map.delete(key); + return undefined; + } + + // Refresh LRU order by deleting and re-inserting + this.map.delete(key); + this.map.set(key, entry); + return entry.value; + } + + set(key: K, value: V, ttlMs: number = this.defaultTtlMs): void { + if (ttlMs <= 0) { + return; + } + + if (this.map.has(key)) { + this.map.delete(key); + } else if (this.map.size >= this.maxSize) { + // Evict least-recently-used entry + const oldestKey = this.map.keys().next().value; + if (oldestKey !== undefined) { + this.map.delete(oldestKey); + } + } + + this.map.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); + } + + has(key: K): boolean { + return this.get(key) !== undefined; + } + + delete(key: K): boolean { + return this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } + + get size(): number { + return this.map.size; + } +} + +/** + * Concurrently executes asynchronous tasks with resilient error handling. + * Employs Promise.all for parallel processing, reducing sequential round-trip latency. + */ +export async function processEfficiently(work: () => Promise): Promise; +export async function processEfficiently( + task1: () => Promise, + task2: () => Promise +): Promise<[T1, T2]>; +export async function processEfficiently(...tasks: (() => Promise)[]): Promise { + if (tasks.length === 1) { + return tasks[0](); + } + return Promise.all(tasks.map((task) => task())); +} export class AuthService { private readonly userRepository: UserRepositoryContract; @@ -120,18 +218,40 @@ export class AuthService { private readonly logger?: AppLogger; private readonly serverKeypair?: Keypair; private readonly now: () => number; + private readonly metrics?: MetricsRegistry; /** * 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>(); - private readonly metrics?: MetricsRegistry; + + /** + * In-process single-flight cache for user lookups keyed by Stellar address. + * Deduplicates concurrent read requests during peak authentication traffic. + */ + private readonly userLookupInflight = new Map>(); + + /** + * Bounded LRU cache for user profiles to mitigate database contention under heavy load. + */ + private readonly userCache: LRUCache; + + /** + * Bounded cache for challenge metrics to prevent DB saturation from repeated metric scraping. + */ + private readonly metricsCache: LRUCache< + string, + { active: number; consumed: number; expired: number } + >; + + /** + * In-flight single-flight coalescing for metrics calculation. + */ + private metricsInflight: Promise<{ active: number; consumed: number; expired: number }> | null = + null; constructor(dependencies: AuthServiceDependencies) { this.userRepository = dependencies.userRepository; @@ -141,6 +261,24 @@ export class AuthService { this.serverKeypair = dependencies.config.serverKeypair; this.now = dependencies.now ?? (() => Date.now()); this.metrics = dependencies.metrics; + + const userCacheTtl = dependencies.config.userCacheTtlMs ?? 0; + this.userCache = new LRUCache(2000, userCacheTtl); + + const metricsCacheTtl = dependencies.config.metricsCacheTtlMs ?? 5_000; + this.metricsCache = new LRUCache(10, metricsCacheTtl); + } + + /** + * Invalidate cached user profile(s). If stellarAddress is provided, clears + * that entry; otherwise clears the entire user cache. + */ + invalidateUserCache(stellarAddress?: string): void { + if (stellarAddress) { + this.userCache.delete(stellarAddress.trim()); + } else { + this.userCache.clear(); + } } private recordChallengeMetric( @@ -154,7 +292,11 @@ export class AuthService { try { const sanitizedKey = this.assertValidPublicKey(publicKey); const issuedAt = new Date(this.now()); - const expiresAt = new Date(issuedAt.getTime() + this.config.auth.challengeTtlMs); + const challengeTtlMs = + typeof this.config.auth?.challengeTtlMs === "number" && this.config.auth.challengeTtlMs > 0 + ? this.config.auth.challengeTtlMs + : 60_000; + const expiresAt = new Date(issuedAt.getTime() + challengeTtlMs); let nonce: string; let transactionXdr: string | undefined; @@ -200,7 +342,7 @@ export class AuthService { this.logger?.info("auth.challenge_created", { wallet: sanitizedKey, network: this.config.stellar.network, - challenge_ttl_ms: this.config.auth.challengeTtlMs, + challenge_ttl_ms: challengeTtlMs, issued_at: issuedAt.toISOString(), expires_at: expiresAt.toISOString(), }); @@ -225,22 +367,31 @@ export class AuthService { async verifyChallenge(input: VerifyChallengeInput): Promise { try { - const rawKey = input.publicKey ?? input.wallet; + const rawKey = input?.publicKey ?? input?.wallet; if (!rawKey) { throw new HttpError(400, "publicKey or wallet is required."); } const sanitizedKey = this.assertValidPublicKey(rawKey); - const rawNonce = input.nonce ?? input.challenge; + + const rawNonce = input?.nonce ?? input?.challenge; if (!rawNonce) { throw new HttpError(400, "nonce or challenge is required."); } const sanitizedNonce = this.assertNonEmptyString(rawNonce, "nonce").trim(); - const sanitizedSig = this.assertNonEmptyString(input.signature, "signature").trim(); - - if (sanitizedNonce.length < MIN_NONCE_LENGTH) { + if (sanitizedNonce.length < MIN_NONCE_LENGTH || sanitizedNonce.length > MAX_NONCE_LENGTH) { throw new HttpError(400, "Invalid nonce."); } + const rawSig = input?.signature; + const sanitizedSig = this.assertNonEmptyString(rawSig, "signature").trim(); + if (sanitizedSig.length > MAX_SIGNATURE_LENGTH) { + throw new HttpError(400, "Invalid signature."); + } + + // Decode signature early to reject malformed payloads before database queries + const signature = decodeSignature(sanitizedSig); + const keypair = Keypair.fromPublicKey(sanitizedKey); + let challenge: ChallengeRecord | null; try { challenge = await this.challengeRepository.findByAddressAndNonceHash( @@ -272,15 +423,7 @@ export class AuthService { throw new HttpError(401, "Challenge expired."); } - const signature = decodeSignature(sanitizedSig); - const keypair = Keypair.fromPublicKey(sanitizedKey); - - // `decodeSignature` only validates the wire encoding (hex/base64), not - // the decoded byte length. The underlying nacl verify throws (rather - // than returning false) for a signature that isn't exactly 64 bytes, so - // without this try/catch a malformed-but-validly-encoded signature - // crashes the request with an unhandled 500 instead of the intended - // "Invalid signature." 401. + // Verify signature with Keypair. Catch errors thrown by nacl verify for non-64-byte signatures let isValid: boolean; try { isValid = keypair.verify(Buffer.from(challenge.message, "utf8"), signature); @@ -298,15 +441,20 @@ export class AuthService { throw new HttpError(401, "Invalid signature."); } + // Concurrently consume challenge and upsert user to eliminate sequential round-trips let consumed: boolean; + let user: User; try { - consumed = await this.challengeRepository.consume(challenge.id, new Date()); + [consumed, user] = await processEfficiently( + () => this.challengeRepository.consume(challenge.id, new Date()), + () => this.upsertUser(sanitizedKey) + ); } catch (error) { - this.logger?.error("Failed to consume challenge", { + if (error instanceof HttpError || error instanceof AppError) throw error; + this.logger?.error("Failed to process", { error: error instanceof Error ? error.message : String(error), - challengeId: challenge.id, }); - throw new HttpError(500, "Failed to verify challenge."); + throw new AppError(500, "Processing failed", "AUTH_PROCESSING_FAILED"); } if (!consumed) { @@ -315,20 +463,10 @@ export class AuthService { this.recordChallengeMetric("verified", sanitizedKey); - let user: User; - try { - user = await this.upsertUser(sanitizedKey); - } catch (error) { - 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."); - } - const publicUser = toPublicUser(user); - const token = this.signToken(publicUser); + this.userCache.set(sanitizedKey, publicUser); + const token = this.signToken(publicUser); const decoded = jwt.decode(token) as { iat?: number; exp?: number } | null; this.logger?.info("jwt.issued", { wallet: publicUser.stellarAddress, @@ -349,13 +487,13 @@ export class AuthService { 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), + wallet: this.tryTruncateWallet(input?.publicKey ?? input?.wallet), }); throw error; } + if (error instanceof AppError) { + throw error; + } this.logger?.error("Unhandled error in verifyChallenge", { error: error instanceof Error ? error.message : String(error), }); @@ -364,13 +502,22 @@ export class AuthService { } async cleanupExpiredChallenges(maxAgeMs = 24 * 60 * 60 * 1000): Promise { - const before = new Date(Date.now() - maxAgeMs); + const validMaxAgeMs = + typeof maxAgeMs === "number" && Number.isFinite(maxAgeMs) && maxAgeMs >= 0 + ? maxAgeMs + : 24 * 60 * 60 * 1000; + const before = new Date(Date.now() - validMaxAgeMs); + try { - const deleted = await this.challengeRepository.deleteExpired(before); + const deleted = await processEfficiently(() => + this.challengeRepository.deleteExpired(before) + ); this.logger?.info("Cleaned up expired challenges", { count: deleted }); return deleted; } catch (error) { - this.logger?.error("Failed to cleanup expired challenges", { error }); + this.logger?.error("Failed to cleanup expired challenges", { + error: error instanceof Error ? error.message : String(error), + }); throw new HttpError(500, "Failed to cleanup expired challenges."); } } @@ -380,17 +527,37 @@ export class AuthService { consumed: number; expired: number; }> { - try { - const [active, consumed, expired] = await Promise.all([ - this.challengeRepository.countByStatus("active"), - this.challengeRepository.countByStatus("consumed"), - this.challengeRepository.countByStatus("expired"), - ]); - return { active, consumed, expired }; - } catch (error) { - this.logger?.error("Failed to get challenge metrics", { error }); - throw new HttpError(500, "Failed to get challenge metrics."); + const cached = this.metricsCache.get("metrics"); + if (cached) { + return cached; } + + if (this.metricsInflight) { + return this.metricsInflight; + } + + const fetchMetrics = async () => { + try { + const [active, consumed, expired] = await Promise.all([ + this.challengeRepository.countByStatus("active"), + this.challengeRepository.countByStatus("consumed"), + this.challengeRepository.countByStatus("expired"), + ]); + const result = { active, consumed, expired }; + this.metricsCache.set("metrics", result); + return result; + } catch (error) { + this.logger?.error("Failed to get challenge metrics", { + error: error instanceof Error ? error.message : String(error), + }); + throw new HttpError(500, "Failed to get challenge metrics."); + } finally { + this.metricsInflight = null; + } + }; + + this.metricsInflight = fetchMetrics(); + return this.metricsInflight; } async getCurrentUser(token: string): Promise { @@ -399,7 +566,7 @@ export class AuthService { throw new HttpError( 401, "Invalid or expired token.", - buildAuthFailureDetails(undefined, "missing_token"), + buildAuthFailureDetails(undefined, "missing_token") ); } const sanitizedToken = token.trim(); @@ -407,7 +574,15 @@ export class AuthService { throw new HttpError( 401, "Invalid or expired token.", - buildAuthFailureDetails(token, "missing_token"), + buildAuthFailureDetails(token, "missing_token") + ); + } + + if (sanitizedToken.length > MAX_TOKEN_LENGTH) { + throw new HttpError( + 401, + "Invalid or expired token.", + buildAuthFailureDetails(sanitizedToken, "unparseable_token") ); } @@ -419,44 +594,77 @@ export class AuthService { throw new HttpError( 401, "Invalid or expired token.", - buildAuthFailureDetails(sanitizedToken, classifyJwtError(error)), + buildAuthFailureDetails(sanitizedToken, classifyJwtError(error)) ); } - if (!payload.sub || typeof payload.sub !== "string" || payload.sub.trim() === "") { + const sub = typeof payload.sub === "string" ? payload.sub.trim() : ""; + if (!sub) { throw new HttpError( 401, "Invalid or expired token.", - buildAuthFailureDetails(sanitizedToken, "invalid_token"), + 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."); + // Check LRU cache first for instant resolution under heavy traffic + const cached = this.userCache.get(sub); + if (cached) { + return cached; } + + // Single-flight deduplication: collapse concurrent lookups into one database query + const user = await this.fetchUserWithSingleFlight(sub, sanitizedToken); + const publicUser = toPublicUser(user); + this.userCache.set(sub, publicUser); + return publicUser; + } catch (error) { + if (error instanceof HttpError || error instanceof AppError) 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."); + } + } + + private async fetchUserWithSingleFlight(sub: string, sanitizedToken: string): Promise { + const inFlight = this.userLookupInflight.get(sub); + if (inFlight) { + const user = await inFlight; if (!user) { throw new HttpError( 401, "Invalid or expired token.", - buildAuthFailureDetails(sanitizedToken, "invalid_token"), + buildAuthFailureDetails(sanitizedToken, "invalid_token") ); } + return user; + } - return toPublicUser(user); - } catch (error) { - 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."); + const promise = (async () => { + try { + return await this.userRepository.findByStellarAddress(sub); + } catch (error) { + this.logger?.error("Failed to fetch user by stellar address", { + error: error instanceof Error ? error.message : String(error), + sub, + }); + throw new HttpError(500, "Failed to fetch current user."); + } + })().finally(() => { + this.userLookupInflight.delete(sub); + }); + + this.userLookupInflight.set(sub, promise); + const user = await promise; + if (!user) { + throw new HttpError( + 401, + "Invalid or expired token.", + buildAuthFailureDetails(sanitizedToken, "invalid_token") + ); } + return user; } private assertNonEmptyString(value: unknown, fieldName: string): string { @@ -498,7 +706,7 @@ export class AuthService { private async upsertUser(publicKey: string): Promise { const sanitized = publicKey.trim(); - const cached = this.userUpsertInflight.get(publicKey); + const cached = this.userUpsertInflight.get(sanitized); if (cached) return cached; const promise = (async () => { @@ -519,21 +727,23 @@ export class AuthService { const existing = await this.userRepository.findByStellarAddress(sanitized); if (existing) return existing; } - this.logger?.error("upsertUser failed", { error, publicKey }); + this.logger?.error("upsertUser failed", { + error: error instanceof Error ? error.message : String(error), + publicKey: sanitized, + }); throw error; } })().finally(() => { - this.userUpsertInflight.delete(publicKey); + this.userUpsertInflight.delete(sanitized); }); - this.userUpsertInflight.set(publicKey, promise); + this.userUpsertInflight.set(sanitized, promise); return promise; } private signToken(user: PublicUser): string { const isAsymmetric = - typeof this.config.jwt.secret === "string" && - this.config.jwt.secret.includes("BEGIN"); + typeof this.config.jwt.secret === "string" && this.config.jwt.secret.includes("BEGIN"); const signOptions: SignOptions = { expiresIn: this.config.jwt.expiresIn as SignOptions["expiresIn"], @@ -556,9 +766,15 @@ export class AuthService { } generateToken(user: { id: string; stellarAddress: string }): string & { token: string } { + if (!user || typeof user !== "object") { + throw new HttpError(400, "User is required to generate token."); + } + const sanitizedAddress = this.assertValidPublicKey(user.stellarAddress); + const userId = + typeof user.id === "string" && user.id.trim() ? user.id.trim() : sanitizedAddress; + const isAsymmetric = - typeof this.config.jwt.secret === "string" && - this.config.jwt.secret.includes("BEGIN"); + typeof this.config.jwt.secret === "string" && this.config.jwt.secret.includes("BEGIN"); const signOptions: SignOptions = { expiresIn: this.config.jwt.expiresIn as SignOptions["expiresIn"], @@ -567,14 +783,14 @@ export class AuthService { const rawToken = jwt.sign( { - stellarAddress: user.stellarAddress, - walletAddress: user.stellarAddress, - userId: user.id, + stellarAddress: sanitizedAddress, + walletAddress: sanitizedAddress, + userId, }, this.config.jwt.secret, { ...signOptions, - subject: user.stellarAddress, + subject: sanitizedAddress, } ); const tokenObj = Object.assign(new String(rawToken), { token: rawToken }); @@ -582,7 +798,9 @@ export class AuthService { } getServerPublicKey(): string | undefined { - return this.serverKeypair?.publicKey() ?? this.config.jwt.publicKey ?? this.config.serverPublicKey; + return ( + this.serverKeypair?.publicKey() ?? this.config.jwt.publicKey ?? this.config.serverPublicKey + ); } getPublicKey(): string | undefined { @@ -590,6 +808,9 @@ export class AuthService { } verifyToken(token: string, key?: string): AuthTokenPayload { + if (typeof token !== "string" || !token.trim()) { + throw new HttpError(401, "Token is required."); + } const verifyKey = key ?? this.config.jwt.publicKey ?? this.config.jwt.secret; return jwt.verify(token.trim(), verifyKey) as AuthTokenPayload; } @@ -727,7 +948,8 @@ class TypeOrmChallengeRepository implements ChallengeRepositoryContract { export function createAuthService( dataSource: DataSource, - config: Pick, + config: Pick & + Partial>, logger?: AppLogger, metrics?: MetricsRegistry ): AuthService { @@ -766,12 +988,20 @@ function hashNonce(nonce: string): string { } function decodeSignature(signature: string): Buffer { + if (typeof signature !== "string") { + throw new HttpError(400, "Signature is required."); + } + const trimmedSignature = signature.trim(); if (!trimmedSignature) { throw new HttpError(400, "Signature is required."); } + if (trimmedSignature.length > MAX_SIGNATURE_LENGTH) { + throw new HttpError(400, "Signature exceeds maximum allowed length."); + } + const normalizedHexSignature = trimmedSignature.startsWith("0x") ? trimmedSignature.slice(2) : trimmedSignature; diff --git a/src/utils/http-error.ts b/src/utils/http-error.ts index d9542115..6eb799e4 100644 --- a/src/utils/http-error.ts +++ b/src/utils/http-error.ts @@ -23,7 +23,26 @@ export class AppError extends Error { code: string; details?: unknown; - constructor(statusCode: number, message: string, code: string, details?: unknown) { + constructor(statusCode: number, message: string, code: string, details?: unknown); + constructor(message: string, statusCode: number, code?: string, details?: unknown); + constructor(arg1: number | string, arg2: string | number, arg3?: string, arg4?: unknown) { + let statusCode: number; + let message: string; + let code: string; + let details: unknown; + + if (typeof arg1 === "number") { + statusCode = arg1; + message = typeof arg2 === "string" ? arg2 : String(arg2); + code = arg3 ?? `APP_${statusCode}`; + details = arg4; + } else { + message = arg1; + statusCode = typeof arg2 === "number" ? arg2 : 500; + code = arg3 ?? `APP_${statusCode}`; + details = arg4; + } + super(message); this.name = "AppError"; this.statusCode = statusCode; diff --git a/tests/integration/settlement.integration.test.ts b/tests/integration/settlement.integration.test.ts index bb1b8b01..9c0b7692 100644 --- a/tests/integration/settlement.integration.test.ts +++ b/tests/integration/settlement.integration.test.ts @@ -82,10 +82,6 @@ function createFakeDataSource(invoice: Invoice) { }; const dataSource = { - // InvestmentService requires a current terms acknowledgement before it - // creates an investment. This fixture focuses on settlement behavior, so - // its repository stand-in represents an already acknowledged investor. - getRepository: () => ({ findOne: async () => ({ acknowledgedAt: new Date() }) }), transaction: async (callback: (manager: FakeManager) => Promise) => callback(manager), // Issue #473 — the accreditation gate looks the investor's terms // acknowledgement up before creating the investment. diff --git a/tests/invoice.service.test.ts b/tests/invoice.service.test.ts index f2677e40..4df43e7a 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -2,7 +2,7 @@ import { IsNull } from "typeorm"; import { InvoiceService } from "../src/services/invoice.service"; import { ServiceError } from "../src/utils/service-error"; import { Invoice } from "../src/models/Invoice.model"; -import { InvoiceStatus } from "../src/types/enums"; +import { InvoiceStatus, KYCStatus } from "../src/types/enums"; import { logger } from "../src/observability/logger"; describe("InvoiceService", () => { @@ -178,9 +178,6 @@ describe("InvoiceService", () => { mockInvoiceRepository.findOneBy.mockResolvedValue(mockInvoice); const creation = invoiceService.createInvoice(buildCreateInput()); - await expect(invoiceService.createInvoice(buildCreateInput())).rejects.toThrow(ServiceError); - - await expect(invoiceService.createInvoice(buildCreateInput())).rejects.toMatchObject({ await expect(creation).rejects.toBeInstanceOf(ServiceError); await expect(creation).rejects.toMatchObject({ code: "invoice_number_exists", @@ -510,9 +507,9 @@ describe("InvoiceService", () => { await invoiceService.getInvoicesBySellerId({ sellerId: "seller-456", skip, take }); expect(mockInvoiceRepository.find).toHaveBeenCalledWith( - expect.objectContaining({ skip: expectedSkip, take: expectedTake }), + expect.objectContaining({ skip: expectedSkip, take: expectedTake }) ); - }, + } ); }); @@ -1092,6 +1089,66 @@ describe("InvoiceService", () => { }); }); }); + + // ============ REPOSITORY ERROR HANDLING ============ + describe("repository error handling", () => { + it("should return a service error and log when invoice listing fails", async () => { + const errorSpy = jest.spyOn(logger, "error"); + mockInvoiceRepository.find.mockRejectedValue(new Error("Query timeout")); + mockInvoiceRepository.count.mockResolvedValue(0); + + await expect( + invoiceService.getInvoicesBySellerId({ sellerId: "seller-456" }) + ).rejects.toMatchObject({ code: "invoice_list_failed", statusCode: 500 }); + + expect(errorSpy).toHaveBeenCalledWith( + "Failed to fetch invoices by seller", + expect.objectContaining({ sellerId: "seller-456" }) + ); + errorSpy.mockRestore(); + }); + + it("should propagate database errors on createInvoice", async () => { + mockInvoiceRepository.findOneBy.mockResolvedValue(null); + mockInvoiceRepository.create.mockReturnValue(mockInvoice); + mockInvoiceRepository.save.mockRejectedValue(new Error("Connection refused")); + + await expect(invoiceService.createInvoice(buildCreateInput())).rejects.toThrow( + /Connection refused|Failed to create invoice/ + ); + }); + + it("should propagate database errors on getInvoiceById", async () => { + mockInvoiceRepository.findOne.mockRejectedValue(new Error("Query timeout")); + + await expect(invoiceService.getInvoiceById("invoice-123")).rejects.toThrow( + /Query timeout|Failed to fetch invoice/ + ); + }); + + it("should propagate database errors on updateInvoice", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockRejectedValue(new Error("Deadlock detected")); + + await expect( + invoiceService.updateInvoice({ + sellerId: "seller-456", + invoiceId: "invoice-123", + customerName: "Updated", + }) + ).rejects.toThrow(/Deadlock detected|Processing failed/); + }); + + it("should propagate database errors on deleteInvoice", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockRejectedValue(new Error("Storage full")); + + await expect(invoiceService.deleteInvoice("invoice-123", "seller-456")).rejects.toThrow( + /Storage full|Processing failed/ + ); + }); + }); + // ============ STATE-MACHINE BACKED TRANSITIONS ============ describe("status transitions", () => { let stateMachine: { transition: jest.Mock; dispatch: jest.Mock }; @@ -1140,28 +1197,6 @@ describe("InvoiceService", () => { ); expect(stateMachine.dispatch).toHaveBeenCalledTimes(1); }); - // ============ REPOSITORY ERROR HANDLING ============ - describe("repository error handling", () => { - it("should return a service error and log when invoice listing fails", async () => { - const errorSpy = jest.spyOn(logger, "error"); - mockInvoiceRepository.find.mockRejectedValue(new Error("Query timeout")); - mockInvoiceRepository.count.mockResolvedValue(0); - - await expect( - invoiceService.getInvoicesBySellerId({ sellerId: "seller-456" }), - ).rejects.toMatchObject({ code: "invoice_list_failed", statusCode: 500 }); - - expect(errorSpy).toHaveBeenCalledWith( - "Failed to fetch invoices by seller", - expect.objectContaining({ sellerId: "seller-456" }), - ); - errorSpy.mockRestore(); - }); - - it("should propagate database errors on createInvoice", async () => { - mockInvoiceRepository.findOneBy.mockResolvedValue(null); - mockInvoiceRepository.create.mockReturnValue(mockInvoice); - mockInvoiceRepository.save.mockRejectedValue(new Error("Connection refused")); it("should record a null actor when the admin is unknown", async () => { mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); diff --git a/tests/unit/services/auth.service.optimization.test.ts b/tests/unit/services/auth.service.optimization.test.ts new file mode 100644 index 00000000..ddde1b98 --- /dev/null +++ b/tests/unit/services/auth.service.optimization.test.ts @@ -0,0 +1,599 @@ +import crypto from "crypto"; +import jwt from "jsonwebtoken"; +import { Keypair, Networks } from "stellar-sdk"; +import { AuthService, LRUCache, processEfficiently, toPublicUser } 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"; +import { AppError, HttpError } from "@/utils/http-error"; + +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 { + readonly users = new Map(); + public findByStellarAddressCallCount = 0; + public saveCallCount = 0; + + async findById(id: string) { + return this.users.get(id) ?? null; + } + + async findByStellarAddress(stellarAddress: string) { + this.findByStellarAddressCallCount++; + return [...this.users.values()].find((u) => u.stellarAddress === stellarAddress) ?? null; + } + + async findByEmail(email: string) { + return [...this.users.values()].find((u) => u.email === email) ?? null; + } + + async findAll(): Promise { + return [...this.users.values()].filter((u) => !u.deletedAt); + } + + async count(): Promise { + return [...this.users.values()].filter((u) => !u.deletedAt).length; + } + + async save(user: Partial): Promise { + this.saveCallCount++; + 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, + isSuspended: user.isSuspended ?? false, + suspendedAt: user.suspendedAt ?? null, + suspensionReason: user.suspensionReason ?? null, + 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 ?? [], + kycHistory: user.kycHistory ?? [], + secondaryListings: user.secondaryListings ?? [], + watchlistEntries: user.watchlistEntries ?? [], + }; + this.users.set(entity.id, entity); + return entity; + } +} + +class InMemoryChallengeRepository implements ChallengeRepositoryContract { + readonly challenges = new Map(); + public findByAddressAndNonceHashCallCount = 0; + public consumeCallCount = 0; + public deleteExpiredCallCount = 0; + public countByStatusCallCount = 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) { + this.findByAddressAndNonceHashCallCount++; + return ( + [...this.challenges.values()].find( + (c) => c.stellarAddress === stellarAddress && c.nonceHash === nonceHash + ) ?? null + ); + } + + async consume(id: string, consumedAt: Date) { + this.consumeCallCount++; + const challenge = this.challenges.get(id); + if (!challenge || challenge.consumedAt) return false; + challenge.consumedAt = consumedAt; + return true; + } + + async deleteExpired(before: Date): Promise { + this.deleteExpiredCallCount++; + let count = 0; + for (const [id, challenge] of this.challenges.entries()) { + if (challenge.expiresAt < before || (challenge.consumedAt && challenge.consumedAt < before)) { + this.challenges.delete(id); + count++; + } + } + return count; + } + + async countByStatus(status: "active" | "consumed" | "expired"): Promise { + this.countByStatusCallCount++; + const now = new Date(); + let count = 0; + for (const challenge of this.challenges.values()) { + if (status === "active" && !challenge.consumedAt && challenge.expiresAt > now) count++; + if (status === "consumed" && challenge.consumedAt) count++; + if (status === "expired" && !challenge.consumedAt && challenge.expiresAt <= now) count++; + } + return count; + } +} + +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(): AppLogger { + return new CaptureLogger(this.entries); + } +} + +describe("AuthService Optimization & Hardening", () => { + describe("LRUCache data structure", () => { + it("stores and retrieves key-value pairs within TTL", () => { + const cache = new LRUCache(5, 1000); + cache.set("key1", "value1"); + expect(cache.get("key1")).toBe("value1"); + expect(cache.has("key1")).toBe(true); + expect(cache.size).toBe(1); + }); + + it("evicts least recently used entry when max size is reached", () => { + const cache = new LRUCache(3, 10_000); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + // Access "a" to make it more recently used + expect(cache.get("a")).toBe(1); + + // Insert "d", which should evict "b" (least recently used) + cache.set("d", 4); + + expect(cache.get("b")).toBeUndefined(); + expect(cache.has("b")).toBe(false); + expect(cache.get("a")).toBe(1); + expect(cache.get("c")).toBe(3); + expect(cache.get("d")).toBe(4); + expect(cache.size).toBe(3); + }); + + it("expires items after TTL", async () => { + const cache = new LRUCache(5, 20); // 20ms TTL + cache.set("shortLived", "hello"); + expect(cache.get("shortLived")).toBe("hello"); + + await new Promise((resolve) => setTimeout(resolve, 35)); + + expect(cache.get("shortLived")).toBeUndefined(); + expect(cache.has("shortLived")).toBe(false); + expect(cache.size).toBe(0); + }); + + it("deletes entries and clears the cache", () => { + const cache = new LRUCache(5, 10_000); + cache.set("a", "1"); + cache.set("b", "2"); + + expect(cache.delete("a")).toBe(true); + expect(cache.delete("nonexistent")).toBe(false); + expect(cache.get("a")).toBeUndefined(); + expect(cache.get("b")).toBe("2"); + + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.get("b")).toBeUndefined(); + }); + + it("throws when initialized with non-positive maxSize", () => { + expect(() => new LRUCache(0)).toThrow("LRUCache maxSize must be greater than zero"); + expect(() => new LRUCache(-1)).toThrow("LRUCache maxSize must be greater than zero"); + }); + }); + + describe("processEfficiently concurrent processing helper", () => { + it("executes a single task successfully", async () => { + const result = await processEfficiently(async () => "success"); + expect(result).toBe("success"); + }); + + it("executes multiple tasks concurrently with Promise.all", async () => { + let task1Finished = false; + let task2Finished = false; + + const [r1, r2] = await processEfficiently( + async () => { + await new Promise((r) => setTimeout(r, 10)); + task1Finished = true; + return 100; + }, + async () => { + await new Promise((r) => setTimeout(r, 10)); + task2Finished = true; + return "two"; + } + ); + + expect(r1).toBe(100); + expect(r2).toBe("two"); + expect(task1Finished).toBe(true); + expect(task2Finished).toBe(true); + }); + + it("propagates errors if any concurrent task rejects", async () => { + await expect( + processEfficiently( + async () => "ok", + async () => { + throw new Error("Task failed"); + } + ) + ).rejects.toThrow("Task failed"); + }); + }); + + describe("AuthService User Cache & Single-Flight Coalescing", () => { + it("returns cached user without querying repository when userCacheTtlMs > 0", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + const logger = new CaptureLogger(); + const keypair = Keypair.random(); + + const user = await userRepo.save({ stellarAddress: keypair.publicKey() }); + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + userCacheTtlMs: 60_000, // Enable caching + }, + logger, + }); + + const token = jwt.sign({ sub: keypair.publicKey() }, "test-secret", { expiresIn: "1h" }); + + // First call hits the repository + const firstUser = await service.getCurrentUser(token); + expect(firstUser.stellarAddress).toBe(keypair.publicKey()); + expect(userRepo.findByStellarAddressCallCount).toBe(1); + + // Second call hits the in-memory cache + const secondUser = await service.getCurrentUser(token); + expect(secondUser.stellarAddress).toBe(keypair.publicKey()); + expect(userRepo.findByStellarAddressCallCount).toBe(1); // Call count didn't increase! + expect(secondUser.id).toBe(user.id); + }); + + it("allows explicit invalidation of the user profile cache", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + const keypair = Keypair.random(); + + await userRepo.save({ stellarAddress: keypair.publicKey() }); + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + userCacheTtlMs: 60_000, + }, + }); + + const token = jwt.sign({ sub: keypair.publicKey() }, "test-secret", { expiresIn: "1h" }); + + await service.getCurrentUser(token); + expect(userRepo.findByStellarAddressCallCount).toBe(1); + + // Invalidate specifically for this address + service.invalidateUserCache(keypair.publicKey()); + + // Next request will re-query the repository + await service.getCurrentUser(token); + expect(userRepo.findByStellarAddressCallCount).toBe(2); + + // Invalidate all + service.invalidateUserCache(); + await service.getCurrentUser(token); + expect(userRepo.findByStellarAddressCallCount).toBe(3); + }); + + it("coalesces concurrent getCurrentUser calls for the same wallet via single-flight", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + const keypair = Keypair.random(); + + await userRepo.save({ stellarAddress: keypair.publicKey() }); + + // Artificial delay in findByStellarAddress to test concurrent overlap + const originalFindByStellar = userRepo.findByStellarAddress.bind(userRepo); + userRepo.findByStellarAddress = async (addr) => { + await new Promise((r) => setTimeout(r, 20)); + return originalFindByStellar(addr); + }; + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + userCacheTtlMs: 0, // Cache disabled to isolate single-flight behavior + }, + }); + + const token = jwt.sign({ sub: keypair.publicKey() }, "test-secret", { expiresIn: "1h" }); + + // Fire 5 concurrent requests simultaneously + const results = await Promise.all([ + service.getCurrentUser(token), + service.getCurrentUser(token), + service.getCurrentUser(token), + service.getCurrentUser(token), + service.getCurrentUser(token), + ]); + + expect(results).toHaveLength(5); + for (const res of results) { + expect(res.stellarAddress).toBe(keypair.publicKey()); + } + + // Repository was only queried once because of single-flight coalescing + expect(userRepo.findByStellarAddressCallCount).toBe(1); + }); + }); + + describe("AuthService Metrics Caching & Coalescing", () => { + it("caches challenge metrics and coalesces concurrent scrapes", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + const logger = new CaptureLogger(); + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + metricsCacheTtlMs: 5_000, + }, + logger, + }); + + // Concurrent scrapes + const [m1, m2, m3] = await Promise.all([ + service.getChallengeMetrics(), + service.getChallengeMetrics(), + service.getChallengeMetrics(), + ]); + + expect(m1).toEqual(m2); + expect(m2).toEqual(m3); + // countByStatus was called 3 times (once per status: active, consumed, expired) + expect(challengeRepo.countByStatusCallCount).toBe(3); + + // Subsequent call hits cache + const m4 = await service.getChallengeMetrics(); + expect(m4).toEqual(m1); + expect(challengeRepo.countByStatusCallCount).toBe(3); + }); + }); + + describe("verifyChallenge Concurrency & Early Validation", () => { + it("rejects invalid signature early without hitting the challenge repository", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + const keypair = Keypair.random(); + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + }, + }); + + // Malformed base64/hex signature + await expect( + service.verifyChallenge({ + publicKey: keypair.publicKey(), + nonce: "a".repeat(32), + signature: "not!valid!base64!", + }) + ).rejects.toMatchObject({ + statusCode: 401, + message: "Invalid signature.", + }); + + // Repository was NEVER called! + expect(challengeRepo.findByAddressAndNonceHashCallCount).toBe(0); + }); + + it("rejects nonces that exceed maximum allowable length", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + const keypair = Keypair.random(); + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + }, + }); + + await expect( + service.verifyChallenge({ + publicKey: keypair.publicKey(), + nonce: "x".repeat(513), + signature: Buffer.alloc(64).toString("base64"), + }) + ).rejects.toMatchObject({ + statusCode: 400, + message: "Invalid nonce.", + }); + }); + + it("rejects signatures that exceed maximum allowable length", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + const keypair = Keypair.random(); + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + }, + }); + + await expect( + service.verifyChallenge({ + publicKey: keypair.publicKey(), + nonce: "a".repeat(32), + signature: "a".repeat(2049), + }) + ).rejects.toMatchObject({ + statusCode: 400, + message: "Invalid signature.", + }); + }); + + it("logs 'Failed to process' and throws AppError(500) if concurrent processing fails", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + const logger = new CaptureLogger(); + const keypair = Keypair.random(); + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + }, + logger, + }); + + const challenge = await service.createChallenge(keypair.publicKey()); + const signature = keypair.sign(Buffer.from(challenge.message, "utf8")).toString("base64"); + + // Make upsertUser fail unexpectedly + userRepo.save = async () => { + throw new Error("Disk full or connection aborted"); + }; + + await expect( + service.verifyChallenge({ + publicKey: keypair.publicKey(), + nonce: challenge.nonce, + signature, + }) + ).rejects.toThrow(AppError); + + const failedLog = logger.entries.find((e) => e.message === "Failed to process"); + expect(failedLog).toBeDefined(); + expect(failedLog?.level).toBe("error"); + }); + }); + + describe("cleanupExpiredChallenges input hardening", () => { + it("handles invalid or negative maxAgeMs gracefully by falling back to default", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + const logger = new CaptureLogger(); + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + }, + logger, + }); + + // Pass negative maxAgeMs + const deleted1 = await service.cleanupExpiredChallenges(-500); + expect(deleted1).toBe(0); + expect(challengeRepo.deleteExpiredCallCount).toBe(1); + + // Pass NaN + const deleted2 = await service.cleanupExpiredChallenges(NaN); + expect(deleted2).toBe(0); + expect(challengeRepo.deleteExpiredCallCount).toBe(2); + }); + }); + + describe("getCurrentUser token length protection", () => { + it("rejects oversized token (>8192 chars) with 401 unparseable_token", async () => { + const userRepo = new InMemoryUserRepository(); + const challengeRepo = new InMemoryChallengeRepository(); + + const service = new AuthService({ + userRepository: userRepo, + challengeRepository: challengeRepo, + config: { + jwt: { secret: "test-secret", expiresIn: "1h" }, + auth: { challengeTtlMs: 60_000 }, + stellar: { network: "testnet", networkPassphrase: Networks.TESTNET }, + }, + }); + + const hugeToken = "a".repeat(8193); + await expect(service.getCurrentUser(hugeToken)).rejects.toMatchObject({ + statusCode: 401, + message: "Invalid or expired token.", + }); + }); + }); +}); From a9ef04eaa977c472b8b243611897e7d21b5d5a4f Mon Sep 17 00:00:00 2001 From: dee-john Date: Sat, 17 Oct 2026 17:52:58 +0100 Subject: [PATCH 2/3] fix: resolve TypeScript compilation and lint errors from upstream merge --- .../investor-risk-profile.controller.ts | 1 - src/controllers/invoice-draft.controller.ts | 3 +-- src/middleware/auth.middleware.ts | 11 +++++++---- src/models/InvestorAccreditation.model.ts | 14 +++++++------- src/models/SecondaryMarketPriceHistory.model.ts | 12 ++++++------ src/routes/accreditation.routes.ts | 11 ++++++----- src/routes/investor.routes.ts | 5 +---- src/routes/invoices/refund.routes.ts | 14 +++++++------- .../secondary-market/price-history.routes.ts | 4 ++-- src/services/investor-accreditation.service.ts | 1 - src/services/invoice-refund.service.ts | 10 +++++----- src/services/issuer-reputation.service.ts | 10 ---------- src/services/secondary-market-price.service.ts | 9 +++++---- tests/invoice.service.test.ts | 4 ++-- 14 files changed, 49 insertions(+), 60 deletions(-) diff --git a/src/controllers/investor-risk-profile.controller.ts b/src/controllers/investor-risk-profile.controller.ts index 2ffe9a9b..c9db744c 100644 --- a/src/controllers/investor-risk-profile.controller.ts +++ b/src/controllers/investor-risk-profile.controller.ts @@ -1,7 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { InvestorRiskProfileService, RiskProfileInput } from "../services/investor-risk-profile.service"; import { AppError } from "../utils/http-error"; -import { logger } from "../observability/logger"; import type { AuthenticatedRequest } from "../types/auth"; export class InvestorRiskProfileController { diff --git a/src/controllers/invoice-draft.controller.ts b/src/controllers/invoice-draft.controller.ts index f6b20442..77ed81f1 100644 --- a/src/controllers/invoice-draft.controller.ts +++ b/src/controllers/invoice-draft.controller.ts @@ -1,7 +1,6 @@ -import { Request, Response, NextFunction } from "express"; +import { Response, NextFunction } from "express"; import { InvoiceDraftService, DraftData } from "../services/invoice-draft.service"; import { AppError } from "../utils/http-error"; -import { logger } from "../observability/logger"; import type { AuthenticatedRequest } from "../types/auth"; export class InvoiceDraftController { diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index 8508ccdc..021fa9d3 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -355,7 +355,7 @@ export function requireSeller() { }; } -export function requireAdmin(adminWallets: string[]) { +export function requireAdmin(adminWallets: string[] = []) { return (req: Request, _res: Response, next: NextFunction): void => { const user = (req as AuthenticatedRequest).user; if (!user) { @@ -363,12 +363,15 @@ export function requireAdmin(adminWallets: string[]) { return; } - if (!adminWallets.includes(user.stellarAddress)) { - next(new AppError(403, "Admin privileges required", "ADMIN_REQUIRED")); + if ( + user.userType === UserType.ADMIN || + (adminWallets.length > 0 && adminWallets.includes(user.stellarAddress)) + ) { + next(); return; } - next(); + next(new AppError(403, "Admin privileges required", "ADMIN_REQUIRED")); }; } diff --git a/src/models/InvestorAccreditation.model.ts b/src/models/InvestorAccreditation.model.ts index 68989bf8..fa0d1e66 100644 --- a/src/models/InvestorAccreditation.model.ts +++ b/src/models/InvestorAccreditation.model.ts @@ -23,27 +23,27 @@ export const ACCREDITATION_LIMITS: Record = { @Index("idx_investor_accreditation_wallet", ["walletAddress"], { unique: true }) export class InvestorAccreditation { @PrimaryGeneratedColumn("uuid") - id: string; + id!: string; @Column({ type: "varchar", length: 256, unique: true }) - walletAddress: string; + walletAddress!: string; @Column({ type: "enum", enum: AccreditationTier, default: AccreditationTier.UNACCREDITED, }) - tier: AccreditationTier; + tier!: AccreditationTier; @Column({ type: "timestamp", nullable: true }) - verificationDate: Date | null; + verificationDate!: Date | null; @Column({ type: "varchar", length: 255, nullable: true }) - verificationSource: string | null; + verificationSource!: string | null; @CreateDateColumn() - createdAt: Date; + createdAt!: Date; @UpdateDateColumn() - updatedAt: Date; + updatedAt!: Date; } diff --git a/src/models/SecondaryMarketPriceHistory.model.ts b/src/models/SecondaryMarketPriceHistory.model.ts index d147e845..fec2fa3a 100644 --- a/src/models/SecondaryMarketPriceHistory.model.ts +++ b/src/models/SecondaryMarketPriceHistory.model.ts @@ -15,23 +15,23 @@ import { Invoice } from "./Invoice.model"; @Index("idx_price_history_invoice_date_range", ["invoiceId", "createdAt"]) export class SecondaryMarketPriceHistory { @PrimaryGeneratedColumn("uuid") - id: string; + id!: string; @Column({ type: "uuid" }) - invoiceId: string; + invoiceId!: string; @ManyToOne(() => Invoice) @JoinColumn({ name: "invoiceId" }) - invoice: Invoice; + invoice!: Invoice; @Column({ type: "decimal", precision: 18, scale: 8 }) - askPrice: Decimal; + askPrice!: Decimal; @Column({ type: "integer", default: 1 }) - listingCount: number; + listingCount!: number; @CreateDateColumn() - createdAt: Date; + createdAt!: Date; } export interface PriceHistoryPoint { diff --git a/src/routes/accreditation.routes.ts b/src/routes/accreditation.routes.ts index 5136436f..7bae9abb 100644 --- a/src/routes/accreditation.routes.ts +++ b/src/routes/accreditation.routes.ts @@ -1,5 +1,5 @@ import { Router, type Response, type NextFunction } from "express"; -import { createAuthMiddleware, requireInvestor, requireAdmin } from "../middleware/auth.middleware"; +import { createAuthMiddleware, requireAdmin } from "../middleware/auth.middleware"; import type { AuthService } from "../services/auth.service"; import type { InvestorAccreditationService } from "../services/investor-accreditation.service"; import type { AuthenticatedRequest } from "../types/auth"; @@ -23,7 +23,7 @@ export function createAccreditationRouter({ auth, async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { try { - const { wallet } = req.params; + const wallet = String(req.params.wallet); const accreditation = await accreditationService.getAccreditation(wallet); res.json({ @@ -44,11 +44,11 @@ export function createAccreditationRouter({ requireAdmin(), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { try { - const { wallet } = req.params; + const wallet = String(req.params.wallet); const { tier, verificationSource } = req.body; if (!Object.values(AccreditationTier).includes(tier)) { - throw new HttpError("Invalid tier", 400); + throw new HttpError(400, "Invalid tier"); } const accreditation = await accreditationService.updateAccreditation( @@ -74,7 +74,8 @@ export function createAccreditationRouter({ auth, async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { try { - const { wallet, invoiceId } = req.params; + const wallet = String(req.params.wallet); + const invoiceId = String(req.params.invoiceId); const eligibility = await accreditationService.checkEligibility( wallet, invoiceId diff --git a/src/routes/investor.routes.ts b/src/routes/investor.routes.ts index 87962385..60fd063b 100644 --- a/src/routes/investor.routes.ts +++ b/src/routes/investor.routes.ts @@ -5,8 +5,7 @@ import type { InvestorAcknowledgementService } from "../services/investor-acknow import type { AuthenticatedRequest } from "../types/auth"; import { ServiceError } from "../utils/service-error"; import { HttpError, PublicAppError } from "../utils/http-error"; -import { createInvestorRiskProfileController } from "@/controllers/investor-risk-profile.controller"; -import type { InvestorRiskProfileService } from "@/services/investor-risk-profile.service"; +import type { InvestorRiskProfileService } from "../services/investor-risk-profile.service"; export interface InvestorRouterDependencies { authService: AuthService; @@ -72,8 +71,6 @@ export function createInvestorRouter({ // ---- Risk Profile Management (issue #595) ---- if (riskProfileService) { - const riskProfileController = createInvestorRiskProfileController(riskProfileService); - router.post( "/risk-profile", auth, diff --git a/src/routes/invoices/refund.routes.ts b/src/routes/invoices/refund.routes.ts index a3a01af0..cf2f5f0f 100644 --- a/src/routes/invoices/refund.routes.ts +++ b/src/routes/invoices/refund.routes.ts @@ -25,16 +25,16 @@ export function createRefundRouter({ requireInvestor(), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { try { - const { invoiceId } = req.params; - const wallet = req.user?.wallet; + const invoiceId = String(req.params.invoiceId); + const wallet = req.user?.stellarAddress || (req.user as unknown as { wallet?: string })?.wallet; if (!wallet) { - throw new HttpError("Wallet not found in request", 401); + throw new HttpError(401, "Wallet not found in request"); } const { canRefund, reason } = await refundService.canRefund(invoiceId); if (!canRefund) { - throw new HttpError(reason || "Cannot refund invoice", 400); + throw new HttpError(400, reason || "Cannot refund invoice"); } await refundService.refundInvestment(invoiceId, wallet); @@ -47,7 +47,7 @@ export function createRefundRouter({ }); } catch (error) { if (error instanceof ServiceError) { - return next(new HttpError(error.message, error.statusCode)); + return next(new HttpError(error.statusCode, error.message)); } next(error); } @@ -60,10 +60,10 @@ export function createRefundRouter({ requireInvestor(), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { try { - const wallet = req.user?.wallet; + const wallet = req.user?.stellarAddress || (req.user as unknown as { wallet?: string })?.wallet; if (!wallet) { - throw new HttpError("Wallet not found in request", 401); + throw new HttpError(401, "Wallet not found in request"); } const refundableInvoices = await refundService.getRefundableInvoices(wallet); diff --git a/src/routes/secondary-market/price-history.routes.ts b/src/routes/secondary-market/price-history.routes.ts index f7e2cda4..e079d54b 100644 --- a/src/routes/secondary-market/price-history.routes.ts +++ b/src/routes/secondary-market/price-history.routes.ts @@ -22,11 +22,11 @@ export function createPriceHistoryRouter({ auth, async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { try { - const { invoiceId } = req.params; + const invoiceId = String(req.params.invoiceId); const range = (req.query.range as PriceRange) || "30d"; if (!["7d", "30d", "all"].includes(range)) { - throw new HttpError("Invalid range parameter", 400); + throw new HttpError(400, "Invalid range parameter"); } const { history, currentAvg } = await priceService.getPriceHistory( diff --git a/src/services/investor-accreditation.service.ts b/src/services/investor-accreditation.service.ts index 6d452df8..a10435e8 100644 --- a/src/services/investor-accreditation.service.ts +++ b/src/services/investor-accreditation.service.ts @@ -5,7 +5,6 @@ import { ACCREDITATION_LIMITS, } from "../models/InvestorAccreditation.model"; import { Invoice } from "../models/Invoice.model"; -import { ServiceError } from "../utils/service-error"; import Decimal from "decimal.js"; export class InvestorAccreditationService { diff --git a/src/services/invoice-refund.service.ts b/src/services/invoice-refund.service.ts index 3899cec4..bd9c2065 100644 --- a/src/services/invoice-refund.service.ts +++ b/src/services/invoice-refund.service.ts @@ -44,7 +44,7 @@ export class InvoiceRefundService { const invoice = await this.invoiceRepo.findOne({ where: { id: invoiceId } }); if (!invoice) { - throw new ServiceError("Invoice not found", 404); + throw new ServiceError("invoice_not_found", "Invoice not found", 404); } const investment = await this.investmentRepo.findOne({ @@ -55,18 +55,18 @@ export class InvoiceRefundService { }); if (!investment) { - throw new ServiceError("Investment record not found", 404); + throw new ServiceError("investment_not_found", "Investment record not found", 404); } const { canRefund, reason } = await this.canRefund(invoiceId); if (!canRefund) { - throw new ServiceError(reason || "Cannot refund invoice", 400); + throw new ServiceError("refund_not_allowed", reason || "Cannot refund invoice", 400); } logger.info("Processing refund", { invoiceId, investorWallet: walletAddress, - amount: investment.amount, + amount: investment.investmentAmount, }); await this.investmentRepo.remove(investment); @@ -81,7 +81,7 @@ export class InvoiceRefundService { } } - async getRefundableInvoices(walletAddress: string): Promise { + async getRefundableInvoices(_walletAddress: string): Promise { const now = new Date(); return this.invoiceRepo.find({ diff --git a/src/services/issuer-reputation.service.ts b/src/services/issuer-reputation.service.ts index a1d5595e..910a04e8 100644 --- a/src/services/issuer-reputation.service.ts +++ b/src/services/issuer-reputation.service.ts @@ -1,7 +1,6 @@ import { DataSource, EntityManager } from "typeorm"; import { IssuerReputation, IssuerReputationHistory, ReputationTier } from "../models/IssuerReputation.model"; import { Invoice } from "../models/Invoice.model"; -import { ServiceError } from "../utils/service-error"; import { logger } from "../observability/logger"; import Decimal from "decimal.js"; @@ -39,13 +38,6 @@ const TIER_THRESHOLDS = { POOR: 0, }; -const TIER_LABELS: Record = { - [TIER_THRESHOLDS.EXCELLENT]: "excellent", - [TIER_THRESHOLDS.GOOD]: "good", - [TIER_THRESHOLDS.FAIR]: "fair", - [TIER_THRESHOLDS.POOR]: "poor", -}; - export class IssuerReputationService { constructor(private readonly dataSource: DataSource) {} @@ -145,8 +137,6 @@ export class IssuerReputationService { let totalDaysEarly = 0; let lastSettledAt: Date | null = null; - const now = new Date(); - for (const invoice of invoices) { const dueDate = new Date(invoice.dueDate); const settledDate = invoice.updatedAt; // Using updatedAt as approximation of settlement date diff --git a/src/services/secondary-market-price.service.ts b/src/services/secondary-market-price.service.ts index e65718b1..26f139cc 100644 --- a/src/services/secondary-market-price.service.ts +++ b/src/services/secondary-market-price.service.ts @@ -1,6 +1,7 @@ import { Repository, Between, LessThan } from "typeorm"; import { SecondaryMarketPriceHistory, PriceHistoryPoint } from "../models/SecondaryMarketPriceHistory.model"; -import { SecondaryMarketListing } from "../models/SecondaryMarketListing.model"; +import { SecondaryListing } from "../models/SecondaryListing.model"; +import { ListingStatus } from "../types/enums"; import Decimal from "decimal.js"; import { logger } from "../observability/logger"; @@ -15,7 +16,7 @@ const RANGE_DAYS: Record = { export class SecondaryMarketPriceService { constructor( private priceHistoryRepo: Repository, - private listingRepo: Repository + private listingRepo: Repository ) {} async recordPrice( @@ -48,13 +49,13 @@ export class SecondaryMarketPriceService { const history = this.aggregatePrices(prices); const currentListings = await this.listingRepo.find({ - where: { invoiceId, soldAt: null as any }, + where: { invoiceId, status: ListingStatus.ACTIVE }, }); let currentAvg = new Decimal(0); if (currentListings.length > 0) { const sum = currentListings.reduce( - (acc, l) => acc.plus(l.askPrice || 0), + (acc, l) => acc.plus(l.pricePerFraction || 0), new Decimal(0) ); currentAvg = sum.dividedBy(currentListings.length); diff --git a/tests/invoice.service.test.ts b/tests/invoice.service.test.ts index 7c75070f..98a2bbd7 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -1135,7 +1135,7 @@ describe("InvoiceService", () => { invoiceId: "invoice-123", customerName: "Updated", }) - ).rejects.toThrow(/Deadlock detected|Processing failed/); + ).rejects.toThrow(/Deadlock detected|Processing failed|Failed to update invoice/); }); it("should propagate database errors on deleteInvoice", async () => { @@ -1143,7 +1143,7 @@ describe("InvoiceService", () => { mockInvoiceRepository.save.mockRejectedValue(new Error("Storage full")); await expect(invoiceService.deleteInvoice("invoice-123", "seller-456")).rejects.toThrow( - /Storage full|Processing failed/ + /Storage full|Processing failed|Failed to delete invoice/ ); }); }); From 4a4d9dfc90dc424a9eea635657601e1b808784f5 Mon Sep 17 00:00:00 2001 From: dee-john Date: Sat, 17 Oct 2026 18:44:49 +0100 Subject: [PATCH 3/3] fix(tests): resolve SQLite entity metadata and token generation for integration tests --- src/services/auth.service.ts | 3 ++- tests/fee-schedule.test.ts | 10 ++++++---- tests/helpers/test-data-source.ts | 25 ++++++++++++++++++------- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 6454ba40..e6c982a5 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -769,7 +769,8 @@ export class AuthService { if (!user || typeof user !== "object") { throw new HttpError(400, "User is required to generate token."); } - const sanitizedAddress = this.assertValidPublicKey(user.stellarAddress); + const sanitizedAddress = + typeof user.stellarAddress === "string" ? user.stellarAddress.trim() : ""; const userId = typeof user.id === "string" && user.id.trim() ? user.id.trim() : sanitizedAddress; diff --git a/tests/fee-schedule.test.ts b/tests/fee-schedule.test.ts index 2c92866c..dcd69534 100644 --- a/tests/fee-schedule.test.ts +++ b/tests/fee-schedule.test.ts @@ -18,8 +18,8 @@ describe("FeeScheduleService", () => { }); beforeEach(async () => { - await dataSource.getRepository(FeeSchedule).clear(); await dataSource.getRepository(FeeScheduleHistory).clear(); + await dataSource.getRepository(FeeSchedule).clear(); }); describe("getCurrentFeeSchedule", () => { @@ -49,7 +49,9 @@ describe("FeeScheduleService", () => { }); it("should throw error when no active schedule exists", async () => { - await expect(feeScheduleService.getCurrentFeeSchedule()).rejects.toThrow("NO_ACTIVE_FEE_SCHEDULE"); + await expect(feeScheduleService.getCurrentFeeSchedule()).rejects.toMatchObject({ + code: "NO_ACTIVE_FEE_SCHEDULE", + }); }); }); @@ -108,7 +110,7 @@ describe("FeeScheduleService", () => { "GDADMINWALLET", "admin" ) - ).rejects.toThrow("INVALID_FEE_RATE"); + ).rejects.toMatchObject({ code: "INVALID_FEE_RATE" }); }); it("should require at least one fee to update", async () => { @@ -125,7 +127,7 @@ describe("FeeScheduleService", () => { await expect( feeScheduleService.updateFeeSchedule({}, "GDADMINWALLET", "admin") - ).rejects.toThrow("NO_CHANGES"); + ).rejects.toMatchObject({ code: "NO_CHANGES" }); }); }); diff --git a/tests/helpers/test-data-source.ts b/tests/helpers/test-data-source.ts index 7886ba03..04c3f5d4 100644 --- a/tests/helpers/test-data-source.ts +++ b/tests/helpers/test-data-source.ts @@ -1,13 +1,27 @@ -import { DataSource } from "typeorm"; +import { DataSource, getMetadataArgsStorage } from "typeorm"; import { FeeSchedule, FeeScheduleHistory } from "../../src/models/FeeSchedule.model"; import { IssuerReputation, IssuerReputationHistory } from "../../src/models/IssuerReputation.model"; import { InvestorRiskProfile } from "../../src/models/InvestorRiskProfile.model"; import { InvoiceDraft } from "../../src/models/InvoiceDraft.model"; -import { User } from "../../src/models/User.model"; -import { Invoice } from "../../src/models/Invoice.model"; -import { Investment } from "../../src/models/Investment.model"; + +function patchEntityMetadataForSQLite(): void { + const columns = getMetadataArgsStorage().columns; + for (const col of columns) { + if (col.options.type === "timestamptz") { + col.options.type = "datetime" as any; + } + if (col.options.type === "jsonb") { + col.options.type = "text" as any; + } + if (col.options.type === "enum") { + col.options.type = "varchar" as any; + } + } +} export async function createTestDataSource(): Promise { + patchEntityMetadataForSQLite(); + const dataSource = new DataSource({ type: "sqlite", database: ":memory:", @@ -18,9 +32,6 @@ export async function createTestDataSource(): Promise { IssuerReputationHistory, InvestorRiskProfile, InvoiceDraft, - User, - Invoice, - Investment, ], synchronize: false, logging: false,