From 31139500511de1e204926ab6ebd89a45db66d0d5 Mon Sep 17 00:00:00 2001 From: CeceOs92 Date: Mon, 31 Aug 2026 12:23:20 +0100 Subject: [PATCH] test: enhance and harden QA suites (e2e, jwt, settlement, invoice.service) Enhances and optimizes four test files per issues #346-#349. All four suites failed to compile on dev (garbled code from #424 / #431), so this also lands the minimal source fixes needed to unblock them. Target suites: 0 -> 101 passing tests. Source fixes (prerequisite): - invoice.service.ts: remove dead try/catch block spliced into getInvoiceTokenHolders (referenced undefined uploadResult / input). - User.model.ts: replace invalid Omit return type on toPublicDTO with an explicit Pick<>; restore missing class-closing brace. - Invoice.model.ts: readonly transition-map type; restore missing brace; drop now-unused BeforeInsert/BeforeUpdate imports. #346 full-flow.e2e.test.ts: - extract duplicated seller/investor challenge-response into authenticateViaChallenge() helper - wrap beforeAll harness bring-up in try/catch with structured logging - add jest.setTimeout(30_000) against sporadic CI timeouts #347 auth-jwt-validation.test.ts: - build the test app once in beforeAll instead of ~25 times - add signToken / getMe / expectRejected helpers; collapse ~20 cases into two it.each tables - assert the middleware's real granular messages (Invalid token payload., User no longer exists.); all failure modes still return the 401 envelope #348 settlement.integration.test.ts: - fix 2 broken tests: the log lookup matched the funding transition (published -> funded) emitted by fullyFundInvoice, not settlement's - add findSettlementTransitionLog / findSettlementCompletionLog helpers and route all log assertions through them #349 invoice.service.test.ts: - add buildCreateInput() factory and wireCreatePassthrough() helper - convert net-amount tests to an it.each table exercising the real calculation, keeping the 29.99 @ 0.5% IEEE-754 regression guard Closes #346 Closes #347 Closes #348 Closes #349 Co-Authored-By: Claude Sonnet 5 --- src/models/Invoice.model.ts | 5 +- src/models/User.model.ts | 15 +- src/services/invoice.service.ts | 15 - tests/e2e/full-flow.e2e.test.ts | 181 +++-- tests/integration/auth-jwt-validation.test.ts | 743 ++++-------------- .../settlement.integration.test.ts | 69 +- tests/invoice.service.test.ts | 137 ++-- 7 files changed, 373 insertions(+), 792 deletions(-) diff --git a/src/models/Invoice.model.ts b/src/models/Invoice.model.ts index 18886a4..6544db0 100644 --- a/src/models/Invoice.model.ts +++ b/src/models/Invoice.model.ts @@ -9,8 +9,6 @@ import { OneToMany, JoinColumn, Index, - BeforeInsert, - BeforeUpdate, } from "typeorm"; import Decimal from "decimal.js"; import { InvoiceStatus } from "../types/enums"; @@ -21,7 +19,7 @@ import { AppError } from "../utils/http-error"; * Frozen state transition map optimized for performance. * Prevents accidental mutations and enables faster lookups. */ -export const VALID_INVOICE_TRANSITIONS: Record = Object.freeze({ +export const VALID_INVOICE_TRANSITIONS: Record = Object.freeze({ [InvoiceStatus.DRAFT]: Object.freeze([InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED, InvoiceStatus.REJECTED]), [InvoiceStatus.PENDING]: Object.freeze([InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED, InvoiceStatus.REJECTED]), [InvoiceStatus.PUBLISHED]: Object.freeze([InvoiceStatus.FUNDED, InvoiceStatus.CANCELLED]), @@ -552,4 +550,5 @@ export class Invoice { ); } } +} diff --git a/src/models/User.model.ts b/src/models/User.model.ts index 339b0fe..b647d21 100644 --- a/src/models/User.model.ts +++ b/src/models/User.model.ts @@ -294,7 +294,19 @@ export class User { /** * Converts user entity to public DTO, excluding sensitive fields. */ - static toPublicDTO(user: User): Omit { + static toPublicDTO( + user: User, + ): Pick< + User, + | "id" + | "stellarAddress" + | "email" + | "userType" + | "kycStatus" + | "isKycVerified" + | "createdAt" + | "updatedAt" + > { try { const dto = { id: user.id, @@ -320,3 +332,4 @@ export class User { ); } } +} diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index 6ad89da..47c8e80 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -802,21 +802,6 @@ export class InvoiceService { "Token holders can only be queried for published invoices", 400 ); - - // Update invoice with IPFS hash - invoice.ipfsHash = uploadResult.hash; - await this.invoiceRepository.save(invoice); - - return { - invoiceId: input.invoiceId, - ipfsHash: uploadResult.hash, - fileSize: uploadResult.size, - uploadedAt: uploadResult.timestamp, - }; - } catch (error) { - if (error instanceof ServiceError) throw error; - logger.error('Failed to process', { error }); - throw new AppError(500, 'Processing failed', 'PROCESSING_FAILED', { error }); } if (!this.dataSource) { diff --git a/tests/e2e/full-flow.e2e.test.ts b/tests/e2e/full-flow.e2e.test.ts index 9bdc52f..2fd96be 100644 --- a/tests/e2e/full-flow.e2e.test.ts +++ b/tests/e2e/full-flow.e2e.test.ts @@ -82,6 +82,49 @@ function toNum(val: unknown): number { return Number(val); } +/** + * Drive the Stellar challenge-response handshake for a keypair and return the + * issued bearer token plus the created user id. + * + * Extracted so seller and investor onboarding share one hardened code path + * instead of duplicating the challenge/sign/verify sequence. Every network + * hop is asserted inline so a regression fails here with a precise message + * rather than cascading into unrelated later steps. + */ +async function authenticateViaChallenge( + httpApp: ReturnType, + keypair: Keypair +): Promise<{ token: string; userId: string }> { + const challengeRes = await request(httpApp) + .post("/api/v1/auth/challenge") + .send({ publicKey: keypair.publicKey() }) + .expect(201); + + expect(challengeRes.body.challenge).toBeDefined(); + expect(challengeRes.body.challenge.publicKey).toBe(keypair.publicKey()); + const { nonce, message } = challengeRes.body.challenge; + expect(nonce).toBeDefined(); + expect(message).toBeDefined(); + + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("hex"); + + const verifyRes = await request(httpApp) + .post("/api/v1/auth/verify") + .send({ publicKey: keypair.publicKey(), nonce, signature }) + .expect(200); + + expect(verifyRes.body.token).toBeDefined(); + expect(verifyRes.body.tokenType).toBe("Bearer"); + expect(verifyRes.body.user?.stellarAddress).toBe(keypair.publicKey()); + + return { token: verifyRes.body.token, userId: verifyRes.body.user.id }; +} + +// The full journey spans two authentications, several writes and a settlement. +// Give it generous headroom so a slow CI runner does not produce a sporadic +// timeout failure that looks like a product regression. +jest.setTimeout(30_000); + describe("E2E: Complete Invoice Financing Flow", () => { let dataSource: DataSource; let app: ReturnType; @@ -168,39 +211,48 @@ describe("E2E: Complete Invoice Financing Flow", () => { }, }; - // Initialize test database (SQLite in-memory) - patchEntityMetadataForSQLite(); - - dataSource = new DataSource({ - type: "sqlite", - database: ":memory:", - synchronize: true, - logging: false, - entities: [User, Invoice, Investment, AuthChallenge, Transaction, KYCVerification, Notification], - }); + // Initialize test database (SQLite in-memory). Wrap the whole bring-up so a + // failure in schema sync or service wiring surfaces with a clear cause + // instead of every downstream test throwing an opaque "app is undefined". + try { + patchEntityMetadataForSQLite(); + + dataSource = new DataSource({ + type: "sqlite", + database: ":memory:", + synchronize: true, + logging: false, + entities: [User, Invoice, Investment, AuthChallenge, Transaction, KYCVerification, Notification], + }); - await dataSource.initialize(); - - // Create services with mocked IPFS - const authService = createAuthService(dataSource, config); - const invoiceService = createInvoiceService(dataSource, mockIPFSService); - const investmentService = createInvestmentService(dataSource); - const settlementService = createSettlementService(dataSource); - const marketplaceService = createMarketplaceService(dataSource); - const notificationService = createNotificationService(dataSource); - - // Create the full app - app = createApp({ - authService, - notificationService, - invoiceService, - investmentService, - settlementService, - marketplaceService, - config, - logger, - metricsEnabled: false, - }); + await dataSource.initialize(); + + // Create services with mocked IPFS + const authService = createAuthService(dataSource, config); + const invoiceService = createInvoiceService(dataSource, mockIPFSService); + const investmentService = createInvestmentService(dataSource); + const settlementService = createSettlementService(dataSource); + const marketplaceService = createMarketplaceService(dataSource); + const notificationService = createNotificationService(dataSource); + + // Create the full app + app = createApp({ + authService, + notificationService, + invoiceService, + investmentService, + settlementService, + marketplaceService, + config, + logger, + metricsEnabled: false, + }); + } catch (error) { + logger.error("E2E test harness failed to initialize", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } }); afterAll(async () => { @@ -214,66 +266,21 @@ describe("E2E: Complete Invoice Financing Flow", () => { // ============================================================ describe("Step 1: Authentication", () => { it("should authenticate seller via Stellar challenge-response", async () => { - // Request challenge - const challengeRes = await request(app) - .post("/api/v1/auth/challenge") - .send({ publicKey: sellerKeypair.publicKey() }) - .expect(201); + const { token, userId } = await authenticateViaChallenge(app, sellerKeypair); + sellerToken = token; + sellerId = userId; - expect(challengeRes.body.challenge).toBeDefined(); - expect(challengeRes.body.challenge.publicKey).toBe(sellerKeypair.publicKey()); - expect(challengeRes.body.challenge.nonce).toBeDefined(); - expect(challengeRes.body.challenge.message).toBeDefined(); - - const { nonce, message } = challengeRes.body.challenge; - - // Sign the challenge message - const signature = sellerKeypair - .sign(Buffer.from(message, "utf8")) - .toString("hex"); - - // Verify challenge and get token - const verifyRes = await request(app) - .post("/api/v1/auth/verify") - .send({ - publicKey: sellerKeypair.publicKey(), - nonce, - signature, - }) - .expect(200); - - expect(verifyRes.body.token).toBeDefined(); - expect(verifyRes.body.tokenType).toBe("Bearer"); - expect(verifyRes.body.user).toBeDefined(); - expect(verifyRes.body.user.stellarAddress).toBe(sellerKeypair.publicKey()); - - sellerToken = verifyRes.body.token; - sellerId = verifyRes.body.user.id; + expect(sellerToken).toEqual(expect.any(String)); + expect(sellerId).toEqual(expect.any(String)); }); it("should authenticate investor via Stellar challenge-response", async () => { - const challengeRes = await request(app) - .post("/api/v1/auth/challenge") - .send({ publicKey: investorKeypair.publicKey() }) - .expect(201); - - const { nonce, message } = challengeRes.body.challenge; - - const signature = investorKeypair - .sign(Buffer.from(message, "utf8")) - .toString("hex"); - - const verifyRes = await request(app) - .post("/api/v1/auth/verify") - .send({ - publicKey: investorKeypair.publicKey(), - nonce, - signature, - }) - .expect(200); + const { token, userId } = await authenticateViaChallenge(app, investorKeypair); + investorToken = token; + investorId = userId; - investorToken = verifyRes.body.token; - investorId = verifyRes.body.user.id; + expect(investorToken).toEqual(expect.any(String)); + expect(investorId).toEqual(expect.any(String)); }); it("should set KYC status to APPROVED for investor (required for investments)", async () => { diff --git a/tests/integration/auth-jwt-validation.test.ts b/tests/integration/auth-jwt-validation.test.ts index 995a307..d0df965 100644 --- a/tests/integration/auth-jwt-validation.test.ts +++ b/tests/integration/auth-jwt-validation.test.ts @@ -50,6 +50,7 @@ class InMemoryUserRepository implements UserRepositoryContract { 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, @@ -132,609 +133,201 @@ function createTestApp() { return createApp({ authService }); } -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe("JWT authentication validation", () => { - it("rejects GET /api/v1/auth/me when the JWT is signed with an invalid secret key", async () => { - const app = createTestApp(); - - const forgedToken = jwt.sign( - { - sub: "GFORGED_STELLAR_ADDRESS", - stellarAddress: "GFORGED_STELLAR_ADDRESS", - userId: crypto.randomUUID(), - }, - "invalid-secret-key", - { expiresIn: "15m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${forgedToken}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); - - it("rejects GET /api/v1/auth/me with expired JWT token", async () => { - const app = createTestApp(); - - const expiredToken = jwt.sign( - { - sub: "GEXPIRED_STELLAR_ADDRESS", - stellarAddress: "GEXPIRED_STELLAR_ADDRESS", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "-5m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${expiredToken}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); - - it("returns 401 from /me when the bearer token is missing", async () => { - const app = createTestApp(); - - const response = await request(app).get("/api/v1/auth/me").expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Authorization token is required.", - }, - }); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// JWT validation: malformed tokens -// ═══════════════════════════════════════════════════════════════════════════ - -describe("JWT validation: malformed tokens", () => { - it("rejects a completely random non-JWT string", async () => { - const app = createTestApp(); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", "Bearer not-a-jwt-at-all") - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); - - it("rejects a token with only two parts (missing signature)", async () => { - const app = createTestApp(); - - // A valid JWT has three base64url parts separated by dots. - // Create one with only header.payload (no signature). - const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); - const payload = Buffer.from( - JSON.stringify({ sub: "GTESTADDRESS", stellarAddress: "GTESTADDRESS" }), - ).toString("base64url"); - const twoPartToken = `${header}.${payload}`; - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${twoPartToken}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); - - it("rejects an empty string as token", async () => { - const app = createTestApp(); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", "Bearer ") - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Authorization token is required.", - }, - }); - }); - - it("rejects a token with invalid base64url encoding in payload", async () => { - const app = createTestApp(); - - const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); - // Use invalid base64 characters - const invalidPayload = "!!!invalid-base64!!!"; - const badToken = `${header}.${invalidPayload}.signature`; +/** + * Every case in this file asserts a rejection on GET /api/v1/auth/me and never + * creates a user, so the app carries no per-test state. Build it once and reuse + * it — this removes ~25 redundant AuthService/Express constructions from the run. + */ +let app: ReturnType; - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${badToken}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); +beforeAll(() => { + app = createTestApp(); }); -// ═══════════════════════════════════════════════════════════════════════════ -// JWT validation: algorithm and signing attacks -// ═══════════════════════════════════════════════════════════════════════════ - -describe("JWT validation: algorithm and signing attacks", () => { - it("rejects a token signed with 'none' algorithm", async () => { - const app = createTestApp(); - - // jwt.sign with algorithm: 'none' produces an unsigned token - const unsignedToken = jwt.sign( - { - sub: "GNONEALGADDRESS", - stellarAddress: "GNONEALGADDRESS", - userId: crypto.randomUUID(), - }, - "", - { algorithm: "none", expiresIn: "15m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${unsignedToken}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); - - it("rejects a token signed with a different HS256 secret", async () => { - const app = createTestApp(); - - const token = jwt.sign( - { - sub: "GDIFFERENT_SECRET_ADDR", - stellarAddress: "GDIFFERENT_SECRET_ADDR", - userId: crypto.randomUUID(), - }, - "completely-different-secret", - { algorithm: "HS256", expiresIn: "15m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${token}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); - - it("rejects a token where the header is tampered to use HS256 but was originally signed with a different key", async () => { - const app = createTestApp(); - - // Sign with one secret, then manually change the alg in the header - // and re-encode to simulate an alg-switch attack - const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); - const payload = Buffer.from( - JSON.stringify({ - sub: "GTAMPEREDADDR", - stellarAddress: "GTAMPEREDADDR", - userId: crypto.randomUUID(), - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 900, - }), - ).toString("base64url"); - - // Sign the tampered header.payload with a wrong secret - const signature = require("crypto") - .createHmac("sha256", "wrong-secret") - .update(`${header}.${payload}`) - .digest("base64url"); - - const tamperedToken = `${header}.${payload}.${signature}`; - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${tamperedToken}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// JWT validation: missing or invalid claims -// ═══════════════════════════════════════════════════════════════════════════ - -describe("JWT validation: missing or invalid claims", () => { - it("rejects a token with no sub claim", async () => { - const app = createTestApp(); - - const token = jwt.sign( - { - stellarAddress: "GNOSUBCLAIM", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${token}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); - - it("rejects a token with an empty sub claim", async () => { - const app = createTestApp(); - - const token = jwt.sign( - { - sub: "", - stellarAddress: "", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${token}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); +/** + * Build a signed token without repeating the claim/option boilerplate. + * Defaults to the app's real secret and a 15m expiry; override `secret` to + * forge, or pass `expiresIn` / `algorithm` / `notBefore` for the edge cases. + */ +function signToken( + payload: Record, + overrides: jwt.SignOptions & { secret?: string } = {}, +): string { + const { secret = VALID_JWT_SECRET, ...options } = overrides; + return jwt.sign(payload, secret, { expiresIn: "15m", ...options }); +} - it("rejects a valid JWT for a user that does not exist in the repository", async () => { - const app = createTestApp(); +/** Base claims for a well-formed token; override per case. */ +function claims(overrides: Record = {}): Record { + return { + sub: "GTESTSUBJECT", + stellarAddress: "GTESTSUBJECT", + userId: crypto.randomUUID(), + ...overrides, + }; +} - const token = jwt.sign( - { - sub: "GNONEXISTENTUSERADDRESS", - stellarAddress: "GNONEXISTENTUSERADDRESS", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); +/** Issue GET /api/v1/auth/me with the given Authorization header value. */ +function getMe(authorization?: string) { + const req = request(app).get("/api/v1/auth/me"); + return authorization === undefined ? req : req.set("Authorization", authorization); +} - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${token}`) - .expect(401); +/** + * Canonical middleware / auth-service messages (see src/middleware/auth.middleware.ts + * and src/services/auth.service.ts#getCurrentUser). Every failure mode still + * returns 401 with the standard envelope; only the message differentiates them. + */ +const INVALID_TOKEN_MESSAGE = "Invalid or expired token."; // unverifiable / undecodable token +const INVALID_PAYLOAD_MESSAGE = "Invalid token payload."; // verified token, missing/empty sub +const UNKNOWN_USER_MESSAGE = "User no longer exists."; // verified token, sub resolves to no user +const MISSING_TOKEN_MESSAGE = "Authorization token is required."; // no usable Bearer credential + +/** + * Assert the standard 401 rejection envelope. Centralising this keeps every + * case checking the same contract — status, `success:false`, a string + * `error.message`, and no `data` leak — so an envelope regression fails once + * and loudly instead of in whichever test happened to run first. + */ +function expectRejected( + response: Awaited>, + expectedMessage?: string, +): void { + expect(response.status).toBe(401); + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("error"); + expect(typeof response.body.error?.message).toBe("string"); + expect(response.body).not.toHaveProperty("data"); + if (expectedMessage !== undefined) { + expect(response.body.error.message).toBe(expectedMessage); + } +} - expect(response.body).toMatchObject({ - success: false, - error: { - message: expect.stringContaining("Invalid or expired token."), - }, - }); - }); +// Hand-rolled tokens that jsonwebtoken cannot produce directly. +function twoSegmentToken(): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ sub: "GTESTADDRESS", stellarAddress: "GTESTADDRESS" }), + ).toString("base64url"); + return `${header}.${payload}`; +} - it("rejects a token with sub set to a non-string value", async () => { - const app = createTestApp(); +function undecodablePayloadToken(): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + return `${header}.!!!invalid-base64!!!.signature`; +} - const token = jwt.sign( - { - sub: 12345, - stellarAddress: "GNOTASTRING", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); +function algSwitchToken(): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + sub: "GTAMPEREDADDR", + stellarAddress: "GTAMPEREDADDR", + userId: crypto.randomUUID(), + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 900, + }), + ).toString("base64url"); + const signature = crypto + .createHmac("sha256", "wrong-secret") + .update(`${header}.${payload}`) + .digest("base64url"); + return `${header}.${payload}.${signature}`; +} - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${token}`) - .expect(401); +// ── Tests ───────────────────────────────────────────────────────────────────── - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); +describe("JWT validation: rejects invalid tokens with a 401 envelope", () => { + // [label, tokenFactory, expectedMessage] + const cases: Array<[string, () => string, string]> = [ + ["signed with an unknown secret key", () => signToken(claims(), { secret: "invalid-secret-key" }), INVALID_TOKEN_MESSAGE], + [ + "signed with a different HS256 secret", + () => signToken(claims(), { secret: "completely-different-secret", algorithm: "HS256" }), + INVALID_TOKEN_MESSAGE, + ], + ["expired", () => signToken(claims(), { expiresIn: "-5m" }), INVALID_TOKEN_MESSAGE], + ["not yet valid (nbf set in the future)", () => signToken(claims(), { notBefore: "1h" }), INVALID_TOKEN_MESSAGE], + ["signed with the 'none' algorithm", () => signToken(claims(), { secret: "", algorithm: "none" }), INVALID_TOKEN_MESSAGE], + ["a completely random non-JWT string", () => "not-a-jwt-at-all", INVALID_TOKEN_MESSAGE], + ["missing its signature segment", twoSegmentToken, INVALID_TOKEN_MESSAGE], + ["carrying an undecodable base64url payload", undecodablePayloadToken, INVALID_TOKEN_MESSAGE], + ["header alg-switched and re-signed with the wrong key", algSwitchToken, INVALID_TOKEN_MESSAGE], + [ + "verified but missing the sub claim", + () => signToken({ stellarAddress: "GNOSUBCLAIM", userId: crypto.randomUUID() }), + INVALID_PAYLOAD_MESSAGE, + ], + ["verified but carrying an empty sub claim", () => signToken(claims({ sub: "", stellarAddress: "" })), INVALID_PAYLOAD_MESSAGE], + ["verified but carrying a non-string sub claim", () => signToken(claims({ sub: 12345 })), UNKNOWN_USER_MESSAGE], + [ + "verified and well-formed but for a user that does not exist", + () => signToken(claims({ sub: "GNONEXISTENTUSERADDRESS", stellarAddress: "GNONEXISTENTUSERADDRESS" })), + UNKNOWN_USER_MESSAGE, + ], + [ + "verified, long-lived, but for a user that does not exist", + () => + signToken( + claims({ sub: "GLONGVALIDTOKEN", stellarAddress: "GLONGVALIDTOKEN" }), + { expiresIn: "365d" }, + ), + UNKNOWN_USER_MESSAGE, + ], + ]; + + it.each(cases)("rejects a token %s", async (_label, makeToken, expectedMessage) => { + const response = await getMe(`Bearer ${makeToken()}`); + expectRejected(response, expectedMessage); }); }); -// ═══════════════════════════════════════════════════════════════════════════ -// JWT validation: Authorization header edge cases -// ═══════════════════════════════════════════════════════════════════════════ - -describe("JWT validation: Authorization header edge cases", () => { - it("rejects request with lowercase 'bearer' prefix", async () => { - const app = createTestApp(); +describe("JWT validation: rejects missing or non-Bearer credentials", () => { + const validToken = signToken(claims()); - const token = jwt.sign( - { - sub: "GLOWERCASEBEARER", - stellarAddress: "GLOWERCASEBEARER", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); + const cases: Array<[string, string | undefined]> = [ + ["no Authorization header is sent", undefined], + ["the bearer value is empty", "Bearer "], + ["the scheme is a lowercase 'bearer'", `bearer ${validToken}`], + ["there is no scheme prefix", validToken], + ["the scheme is 'Token' instead of 'Bearer'", `Token ${validToken}`], + ]; - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `bearer ${token}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Authorization token is required.", - }, - }); + it.each(cases)("returns the missing-token 401 when %s", async (_label, authorization) => { + const response = await getMe(authorization); + expectRejected(response, MISSING_TOKEN_MESSAGE); }); - it("rejects request with raw token (no Bearer prefix)", async () => { - const app = createTestApp(); - - const token = jwt.sign( - { - sub: "GNOBEARERPREFIX", - stellarAddress: "GNOBEARERPREFIX", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", token) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Authorization token is required.", - }, - }); - }); - - it("rejects request with 'Token' scheme instead of 'Bearer'", async () => { - const app = createTestApp(); - - const token = jwt.sign( - { - sub: "GTOKENSCHEME", - stellarAddress: "GTOKENSCHEME", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Token ${token}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Authorization token is required.", - }, - }); - }); - - it("rejects request with extra whitespace around a valid token", async () => { - const app = createTestApp(); - - const token = jwt.sign( - { - sub: "GEXTRAWHITESPACE", - stellarAddress: "GEXTRAWHITESPACE", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); - - // The server should trim the token before verification - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${token} `) - .expect(401); - - // Depending on implementation, extra whitespace may cause signature mismatch - expect(response.body).toMatchObject({ - success: false, - }); + it("rejects (401) a Bearer value padded with surrounding whitespace", async () => { + // Implementations differ on whether the padding is trimmed before verify; + // only the rejection envelope is guaranteed here. + const response = await getMe(`Bearer ${validToken} `); + expectRejected(response); }); }); -// ═══════════════════════════════════════════════════════════════════════════ -// JWT validation: error response structure -// ═══════════════════════════════════════════════════════════════════════════ - -describe("JWT validation: error response structure", () => { - it("returns consistent error envelope on forged token", async () => { - const app = createTestApp(); - - const forgedToken = jwt.sign( - { - sub: "GSTRUCTTEST1", - stellarAddress: "GSTRUCTTEST1", - }, - "wrong-secret", - { expiresIn: "15m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${forgedToken}`) - .expect(401); - - expect(response.body).toHaveProperty("success", false); - expect(response.body).toHaveProperty("error"); - expect(response.body.error).toHaveProperty("message"); - expect(typeof response.body.error.message).toBe("string"); +describe("JWT validation: error envelope shape", () => { + it("uses { success:false, error:{ message } } for a forged token", async () => { + const response = await getMe(`Bearer ${signToken(claims(), { secret: "wrong-secret" })}`); + expectRejected(response, INVALID_TOKEN_MESSAGE); }); - it("returns consistent error envelope on expired token", async () => { - const app = createTestApp(); - - const expiredToken = jwt.sign( - { - sub: "GSTRUCTTEST2", - stellarAddress: "GSTRUCTTEST2", - }, - VALID_JWT_SECRET, - { expiresIn: "-10m" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${expiredToken}`) - .expect(401); - - expect(response.body).toHaveProperty("success", false); - expect(response.body).toHaveProperty("error"); - expect(response.body.error).toHaveProperty("message"); - }); - - it("returns consistent error envelope when no Authorization header is sent", async () => { - const app = createTestApp(); - - const response = await request(app).get("/api/v1/auth/me").expect(401); - - expect(response.body).toHaveProperty("success", false); - expect(response.body).toHaveProperty("error"); - expect(response.body.error).toHaveProperty("message"); + it("uses the same envelope when no Authorization header is sent", async () => { + const response = await getMe(); + expectRejected(response, MISSING_TOKEN_MESSAGE); }); - it("returns 401 (not 403 or 500) for all invalid token scenarios", async () => { - const app = createTestApp(); - - const scenarios = [ - { label: "forged token", token: jwt.sign({ sub: "GX" }, "wrong", { expiresIn: "1m" }) }, - { label: "expired token", token: jwt.sign({ sub: "GX" }, VALID_JWT_SECRET, { expiresIn: "-1m" }) }, - { label: "random string", token: "not-a-jwt" }, + it("always answers 401 (never 403 or 500) across a sample of invalid tokens", async () => { + const tokens = [ + signToken(claims(), { secret: "wrong" }), + signToken(claims(), { expiresIn: "-1m" }), + "not-a-jwt", ]; - for (const scenario of scenarios) { - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${scenario.token}`) - .expect(401); - + for (const token of tokens) { + const response = await getMe(`Bearer ${token}`); + expect(response.status).toBe(401); expect(response.body.success).toBe(false); } }); }); - -// ═══════════════════════════════════════════════════════════════════════════ -// JWT validation: token with future nbf (not yet valid) -// ═══════════════════════════════════════════════════════════════════════════ - -describe("JWT validation: token timing edge cases", () => { - it("rejects a token with nbf set far in the future", async () => { - const app = createTestApp(); - - const futureToken = jwt.sign( - { - sub: "GFUTURETOKEN", - stellarAddress: "GFUTURETOKEN", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m", notBefore: "1h" }, - ); - - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${futureToken}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); - }); - - it("accepts a token with iat set to now and exp set to far future", async () => { - const app = createTestApp(); - - // This token is valid for a very long time - const validToken = jwt.sign( - { - sub: "GLONGVALIDTOKEN", - stellarAddress: "GLONGVALIDTOKEN", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "365d" }, - ); - - // The token itself is valid, but the user doesn't exist, so we expect 401 - // with a message about invalid/expired token (not about missing token) - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${validToken}`) - .expect(401); - - expect(response.body).toMatchObject({ - success: false, - error: { - message: expect.stringContaining("Invalid or expired token."), - }, - }); - }); -}); diff --git a/tests/integration/settlement.integration.test.ts b/tests/integration/settlement.integration.test.ts index 92f5e57..f558c12 100644 --- a/tests/integration/settlement.integration.test.ts +++ b/tests/integration/settlement.integration.test.ts @@ -139,6 +139,38 @@ async function fullyFundInvoice( return created; } +// ── Helper: locate a specific structured log call ────────────────────────── +// +// `fullyFundInvoice` emits its own "Invoice lifecycle state transition." log +// (published -> funded, reason "fully_funded") before settlement runs, so a +// bare `.find(([msg]) => msg === "...transition.")` would match the funding +// event instead of the settlement one. Match on the metadata too. + +type LogCall = [string, Record?]; + +function findLogCall( + infoSpy: jest.SpyInstance, + message: string, + predicate: (meta: Record) => boolean = () => true, +): LogCall | undefined { + return (infoSpy.mock.calls as LogCall[]).find( + ([loggedMessage, meta]) => + loggedMessage === message && predicate((meta ?? {}) as Record), + ); +} + +function findSettlementTransitionLog(infoSpy: jest.SpyInstance): LogCall | undefined { + return findLogCall( + infoSpy, + "Invoice lifecycle state transition.", + (meta) => meta.reason === "admin_settled", + ); +} + +function findSettlementCompletionLog(infoSpy: jest.SpyInstance): LogCall | undefined { + return findLogCall(infoSpy, "Settlement flow completed."); +} + // ═══════════════════════════════════════════════════════════════════════════ // Settlement integration: rejecting settlement of non-fully-funded invoices // ═══════════════════════════════════════════════════════════════════════════ @@ -339,9 +371,7 @@ describe("Settlement integration: funding multiple investors then settling", () actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", }); - const completionCall = infoSpy.mock.calls.find( - ([message]) => message === "Settlement flow completed.", - ); + const completionCall = findSettlementCompletionLog(infoSpy); expect(completionCall).toBeDefined(); const metadata = completionCall?.[1] as Record; @@ -368,10 +398,7 @@ describe("Settlement integration: funding multiple investors then settling", () }), ).rejects.toThrow(); - const completionCall = infoSpy.mock.calls.find( - ([message]) => message === "Settlement flow completed.", - ); - expect(completionCall).toBeUndefined(); + expect(findSettlementCompletionLog(infoSpy)).toBeUndefined(); }); }); @@ -747,9 +774,7 @@ describe("Settlement integration: pro-rata distribution edge cases", () => { actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", }); - const lifecycleCall = infoSpy.mock.calls.find( - ([message]) => message === "Invoice lifecycle state transition.", - ); + const lifecycleCall = findSettlementTransitionLog(infoSpy); expect(lifecycleCall).toBeDefined(); const metadata = lifecycleCall?.[1] as Record; @@ -775,10 +800,7 @@ describe("Settlement integration: pro-rata distribution edge cases", () => { }), ).rejects.toThrow(); - const lifecycleCall = infoSpy.mock.calls.find( - ([message]) => message === "Invoice lifecycle state transition.", - ); - expect(lifecycleCall).toBeUndefined(); + expect(findSettlementTransitionLog(infoSpy)).toBeUndefined(); }); }); @@ -809,12 +831,8 @@ describe("Settlement integration: logging verification", () => { actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", }); - const lifecycleCall = infoSpy.mock.calls.find( - ([msg]) => msg === "Invoice lifecycle state transition.", - ); - const completionCall = infoSpy.mock.calls.find( - ([msg]) => msg === "Settlement flow completed.", - ); + const lifecycleCall = findSettlementTransitionLog(infoSpy); + const completionCall = findSettlementCompletionLog(infoSpy); expect(lifecycleCall).toBeDefined(); expect(completionCall).toBeDefined(); @@ -843,14 +861,7 @@ describe("Settlement integration: logging verification", () => { }), ).rejects.toThrow(/Invoice not found/); - const lifecycleCall = infoSpy.mock.calls.find( - ([msg]) => msg === "Invoice lifecycle state transition.", - ); - const completionCall = infoSpy.mock.calls.find( - ([msg]) => msg === "Settlement flow completed.", - ); - - expect(lifecycleCall).toBeUndefined(); - expect(completionCall).toBeUndefined(); + expect(findSettlementTransitionLog(infoSpy)).toBeUndefined(); + expect(findSettlementCompletionLog(infoSpy)).toBeUndefined(); }); }); diff --git a/tests/invoice.service.test.ts b/tests/invoice.service.test.ts index 0c5c8cd..5b69a43 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -46,25 +46,37 @@ describe("InvoiceService", () => { }); }); + /** Valid `createInvoice` input; override any field per case. */ + const buildCreateInput = (overrides: Record = {}) => ({ + sellerId: "seller-456", + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "5.00", + dueDate: new Date("2024-12-31"), + ...overrides, + }); + + /** + * Wire the repository so `create`/`save` echo the entity the service builds, + * letting a test assert on the values the service actually computed (e.g. + * netAmount) rather than on a hand-stubbed return value. + */ + const wireCreatePassthrough = () => { + mockInvoiceRepository.findOneBy.mockResolvedValue(null); + mockInvoiceRepository.create.mockImplementation((data: Partial) => ({ + ...mockInvoice, + ...data, + })); + mockInvoiceRepository.save.mockImplementation(async (invoice: Invoice) => invoice); + }; + // ============ CREATE INVOICE TESTS ============ describe("createInvoice", () => { it("should successfully create an invoice", async () => { - mockInvoiceRepository.findOneBy.mockResolvedValue(null); - const createdInvoice = { - ...mockInvoice, - netAmount: "950.0000", - }; - mockInvoiceRepository.create.mockReturnValue(createdInvoice); - mockInvoiceRepository.save.mockResolvedValue(createdInvoice); + wireCreatePassthrough(); - const result = await invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-001", - customerName: "Test Customer", - amount: "1000.00", - discountRate: "5.00", - dueDate: new Date("2024-12-31"), - }); + const result = await invoiceService.createInvoice(buildCreateInput()); expect(result.id).toBe("invoice-123"); expect(result.status).toBe(InvoiceStatus.DRAFT); @@ -74,85 +86,46 @@ describe("InvoiceService", () => { }); }); - it("should calculate net amount correctly", async () => { - mockInvoiceRepository.findOneBy.mockResolvedValue(null); - mockInvoiceRepository.create.mockReturnValue({ - ...mockInvoice, - amount: "1000.00", - discountRate: "10.00", - }); - mockInvoiceRepository.save.mockResolvedValue({ - ...mockInvoice, - amount: "1000.00", - discountRate: "10.00", - netAmount: "900.0000", - }); - - const result = await invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-001", - customerName: "Test Customer", - amount: "1000.00", - discountRate: "10.00", - dueDate: new Date("2024-12-31"), - }); - - expect(result.netAmount).toBe("900.0000"); - }); - - it("should calculate net amount precisely for values where floating-point arithmetic rounds wrong", async () => { - mockInvoiceRepository.findOneBy.mockResolvedValue(null); - mockInvoiceRepository.create.mockImplementation((data: Partial) => ({ - ...mockInvoice, - ...data, - })); - mockInvoiceRepository.save.mockImplementation(async (invoice: Invoice) => invoice); - - // 29.99 - 29.99 * 0.5 / 100: naive `parseFloat` arithmetic here used to - // produce "29.8400" instead of the correct "29.8401" because 29.99 and - // 0.5 aren't exactly representable as IEEE-754 doubles. - const result = await invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-002", - customerName: "Test Customer", - amount: "29.99", - discountRate: "0.5", - dueDate: new Date("2024-12-31"), - }); - - expect(mockInvoiceRepository.create).toHaveBeenCalledWith( - expect.objectContaining({ netAmount: "29.8401" }), - ); - expect(result.netAmount).toBe("29.8401"); - }); + // netAmount = amount - amount * discountRate / 100, rounded to 4 dp. + // The 29.99 @ 0.5% row guards a real regression: naive `parseFloat` + // arithmetic produced "29.8400" instead of "29.8401" because 29.99 and + // 0.5 aren't exactly representable as IEEE-754 doubles. + it.each([ + { amount: "1000.00", discountRate: "10.00", expected: "900.0000", note: "round percentages" }, + { amount: "1000.00", discountRate: "5.00", expected: "950.0000", note: "default case" }, + { amount: "29.99", discountRate: "0.5", expected: "29.8401", note: "IEEE-754 rounding trap" }, + { amount: "10000.0000", discountRate: "0.00", expected: "10000.0000", note: "zero discount" }, + ])( + "computes netAmount = $expected for $amount @ $discountRate% ($note)", + async ({ amount, discountRate, expected }) => { + wireCreatePassthrough(); + + const result = await invoiceService.createInvoice( + buildCreateInput({ amount, discountRate }), + ); + + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ netAmount: expected }), + ); + expect(result.netAmount).toBe(expected); + }, + ); it("should reject duplicate invoice number", async () => { mockInvoiceRepository.findOneBy.mockResolvedValue(mockInvoice); await expect( - invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-001", - customerName: "Test Customer", - amount: "1000.00", - discountRate: "5.00", - dueDate: new Date("2024-12-31"), - }) + invoiceService.createInvoice(buildCreateInput()), ).rejects.toThrow(ServiceError); await expect( - invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-001", - customerName: "Test Customer", - amount: "1000.00", - discountRate: "5.00", - dueDate: new Date("2024-12-31"), - }) + invoiceService.createInvoice(buildCreateInput()), ).rejects.toMatchObject({ code: "invoice_number_exists", statusCode: 409, }); + + expect(mockInvoiceRepository.save).not.toHaveBeenCalled(); }); });