From c58ee3d9da57a9d45b7db50b319a71f755358dde Mon Sep 17 00:00:00 2001 From: DeJune06 Date: Wed, 21 Oct 2026 16:53:18 +0100 Subject: [PATCH 1/2] test(stellar): add unit tests for Stellar config (#604) --- tests/unit/config/stellar.test.ts | 145 ++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/unit/config/stellar.test.ts diff --git a/tests/unit/config/stellar.test.ts b/tests/unit/config/stellar.test.ts new file mode 100644 index 0000000..89be692 --- /dev/null +++ b/tests/unit/config/stellar.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as StellarSdk from "@stellar/stellar-sdk"; + +/** + * #604: Stellar SDK configuration — network passphrase, server + * construction, and platform keypair loading. + * + * src/config/stellar.ts reads the memoized `config` singleton from + * src/config/index.ts, which itself reads process.env once at import time. + * Each case resets the module registry and re-imports both modules with a + * fresh env, following the same pattern as tests/unit/config/cors-origins.test.ts. + */ +async function loadStellarConfig(env: Record) { + vi.resetModules(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + vi.stubEnv(key, ""); + delete process.env[key]; + } else { + vi.stubEnv(key, value); + } + } + return import("../../../src/config/stellar.js"); +} + +// A valid Stellar secret key (not tied to any funded account; used only to +// exercise Keypair.fromSecret's checksum validation in tests). +const VALID_SECRET = "SABFGDEMCBXDUQKC575F2W5QSJ2CLYUPQVCQC4GNSONWXRTWE4ZM3S7Q"; + +describe("Stellar config (#604)", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + describe("getNetworkPassphrase", () => { + it("returns the testnet passphrase when STELLAR_NETWORK is testnet", async () => { + const { getNetworkPassphrase } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_NETWORK: "testnet", + }); + + expect(getNetworkPassphrase()).toBe(StellarSdk.Networks.TESTNET); + }); + + it("returns the public/mainnet passphrase when STELLAR_NETWORK is mainnet", async () => { + const { getNetworkPassphrase } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_NETWORK: "mainnet", + }); + + expect(getNetworkPassphrase()).toBe(StellarSdk.Networks.PUBLIC); + }); + + it("defaults to the testnet passphrase when STELLAR_NETWORK is unset", async () => { + const { getNetworkPassphrase } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_NETWORK: undefined, + }); + + expect(getNetworkPassphrase()).toBe(StellarSdk.Networks.TESTNET); + }); + }); + + describe("getHorizonServer", () => { + it("constructs a Horizon server pointed at the configured URL", async () => { + const { getHorizonServer } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org", + }); + + const server = getHorizonServer(); + expect(server).toBeInstanceOf(StellarSdk.Horizon.Server); + expect(server.serverURL.toString()).toBe("https://horizon-testnet.stellar.org/"); + }); + + it("returns a new Horizon server instance on each call", async () => { + const { getHorizonServer } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org", + }); + + expect(getHorizonServer()).not.toBe(getHorizonServer()); + }); + }); + + describe("getSorobanServer", () => { + it("constructs a Soroban RPC server pointed at the configured URL", async () => { + const { getSorobanServer } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org", + }); + + const server = getSorobanServer(); + expect(server).toBeInstanceOf(StellarSdk.rpc.Server); + }); + + it("returns a new Soroban server instance on each call", async () => { + const { getSorobanServer } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org", + }); + + expect(getSorobanServer()).not.toBe(getSorobanServer()); + }); + }); + + describe("getPlatformKeypair", () => { + it("loads a Keypair from STELLAR_PLATFORM_SECRET", async () => { + const { getPlatformKeypair } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_PLATFORM_SECRET: VALID_SECRET, + }); + + const keypair = getPlatformKeypair(); + expect(keypair).toBeInstanceOf(StellarSdk.Keypair); + expect(keypair.secret()).toBe(VALID_SECRET); + }); + + it("caches the keypair as a singleton across calls", async () => { + const { getPlatformKeypair } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_PLATFORM_SECRET: VALID_SECRET, + }); + + const first = getPlatformKeypair(); + const second = getPlatformKeypair(); + expect(first).toBe(second); + }); + + it("derives the expected public key from the secret", async () => { + const { getPlatformKeypair } = await loadStellarConfig({ + NODE_ENV: "test", + STELLAR_PLATFORM_SECRET: VALID_SECRET, + }); + + const expected = StellarSdk.Keypair.fromSecret(VALID_SECRET).publicKey(); + expect(getPlatformKeypair().publicKey()).toBe(expected); + }); + }); +}); From 13616f80327d8443610842ae7ff38463a914baaa Mon Sep 17 00:00:00 2001 From: DeJune06 Date: Wed, 21 Oct 2026 16:53:18 +0100 Subject: [PATCH 2/2] test(route-limits): add unit tests for route body limits config (#605) --- tests/unit/config/route-body-limits.test.ts | 101 ++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/unit/config/route-body-limits.test.ts diff --git a/tests/unit/config/route-body-limits.test.ts b/tests/unit/config/route-body-limits.test.ts new file mode 100644 index 0000000..3c5584a --- /dev/null +++ b/tests/unit/config/route-body-limits.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { ROUTE_BODY_LIMITS } from "../../../src/config/route-body-limits.js"; + +/** + * #605: per-route body size limits and their wiring into the routes that + * reference them. + * + * "Route matching" is verified by grepping the route modules that import + * ROUTE_BODY_LIMITS for the exact key each route is supposed to be using, + * rather than booting the whole Fastify app — the config module has no + * knowledge of routes itself (it's the routes that reach into it), so this + * is the narrowest check that still exercises the real wiring. + */ +function readSource(relativePath: string): string { + const url = new URL(`../../../${relativePath}`, import.meta.url); + return readFileSync(fileURLToPath(url), "utf8"); +} + +const KB = 1024; +const MB = 1024 * KB; + +describe("ROUTE_BODY_LIMITS (#605)", () => { + describe("limit values", () => { + it("sets profileUpdate to 10KB", () => { + expect(ROUTE_BODY_LIMITS.profileUpdate).toBe(10 * KB); + }); + + it("sets courseImport to 5MB", () => { + expect(ROUTE_BODY_LIMITS.courseImport).toBe(5 * MB); + }); + + it("sets quizAuthoring to 100KB", () => { + expect(ROUTE_BODY_LIMITS.quizAuthoring).toBe(100 * KB); + }); + + it("sets webhookCreate to 10KB", () => { + expect(ROUTE_BODY_LIMITS.webhookCreate).toBe(10 * KB); + }); + + it("orders limits from smallest to largest as profileUpdate/webhookCreate <= quizAuthoring <= courseImport", () => { + expect(ROUTE_BODY_LIMITS.profileUpdate).toBeLessThanOrEqual(ROUTE_BODY_LIMITS.quizAuthoring); + expect(ROUTE_BODY_LIMITS.webhookCreate).toBeLessThanOrEqual(ROUTE_BODY_LIMITS.quizAuthoring); + expect(ROUTE_BODY_LIMITS.quizAuthoring).toBeLessThanOrEqual(ROUTE_BODY_LIMITS.courseImport); + }); + + it("exposes only known, positive numeric limits", () => { + const values = Object.values(ROUTE_BODY_LIMITS); + expect(values.length).toBeGreaterThan(0); + for (const value of values) { + expect(typeof value).toBe("number"); + expect(value).toBeGreaterThan(0); + } + }); + + it("is readonly at the type level (as const) so limits can't drift at runtime", () => { + // `as const` only enforces this at compile time; this asserts the + // object itself isn't frozen either way (documents current behavior) + // without depending on Object.isFrozen, which would be a false + // negative here since the source doesn't call Object.freeze. + expect(ROUTE_BODY_LIMITS).toEqual({ + profileUpdate: 10 * KB, + courseImport: 5 * MB, + quizAuthoring: 100 * KB, + webhookCreate: 10 * KB, + }); + }); + }); + + describe("route matching", () => { + it("wires profileUpdate into the user profile update route", () => { + const source = readSource("src/modules/users/user.routes.ts"); + expect(source).toContain("ROUTE_BODY_LIMITS.profileUpdate"); + }); + + it("wires webhookCreate into the admin webhook creation route", () => { + const source = readSource("src/modules/admin/webhook.routes.ts"); + expect(source).toContain("ROUTE_BODY_LIMITS.webhookCreate"); + }); + + it("wires quizAuthoring into the admin quiz authoring routes", () => { + const source = readSource("src/modules/courses/admin-course.routes.ts"); + const matches = source.match(/ROUTE_BODY_LIMITS\.quizAuthoring/g) ?? []; + expect(matches.length).toBeGreaterThan(0); + }); + + it("does not reference courseImport from any route yet (no current consumer)", () => { + // Documents the current state rather than asserting a requirement: + // if a future route starts using courseImport, this test should be + // updated alongside it rather than silently going stale. + const userRoutes = readSource("src/modules/users/user.routes.ts"); + const webhookRoutes = readSource("src/modules/admin/webhook.routes.ts"); + const courseRoutes = readSource("src/modules/courses/admin-course.routes.ts"); + const anyReferencesCourseImport = [userRoutes, webhookRoutes, courseRoutes].some((src) => + src.includes("ROUTE_BODY_LIMITS.courseImport"), + ); + expect(anyReferencesCourseImport).toBe(false); + }); + }); +});