/** * AI Software Factory — Integration app factory. * * Boots the real NestJS API against the Testcontainers infra: * * 1. Start (shared) Postgres + Redis containers (helpers/containers.ts). * 2. Point the environment at them (DATABASE_URL, REDIS_URL, + random * secrets — no secrets ever come from a checked-in file). * 3. Apply the Prisma schema (`prisma migrate deploy`) — the same * deployment path production uses, so drift is caught early. * 4. Build the Nest application from the repo's AppModule (the production * app wiring, NOT a trimmed TestModule — guards, interceptors, the * BullMQ queue providers, and the WebSocket gateway all run). * * The returned handle gives specs: * - `baseUrl`: the app is listened on an ephemeral 127.0.0.1 port. * - `request`: fetch bound to baseUrl (JSON by default). * - `prisma`: the live PrismaClient for side-effect assertions * (AuditLog rows, WebhookDelivery rows, …). * - `close()`: disconnect prisma + close the app (containers kept alive). * * Usage (top of each spec): * import { createTestApp, type TestAppHandle } from "../helpers/api"; * let app: TestAppHandle; * beforeAll(async () => { app = await createTestApp(); }, 180_000); * afterAll(async () => { await app?.close(); }); */ import { randomBytes } from "node:crypto"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { PrismaClient } from "@prisma/client"; import { getDatabaseUrl, getRedisUrl, stopAllContainers } from "./containers"; const execFileAsync = promisify(execFile); const HERE = path.dirname(fileURLToPath(import.meta.url)); export interface TestAppHandle { /** Base URL the API is listening on (http://127.0.0.1:). */ baseUrl: string; /** fetch-compatible request bound to baseUrl. `redirect` defaults to * "follow" (the fetch default); pass "manual" to observe 3xx redirects * instead of chasing them (e.g. the files download 302 → signed S3 URL). */ request: ( method: string, urlPath: string, opts?: { body?: unknown; headers?: Record; redirect?: RequestRedirect }, ) => Promise; /** Live PrismaClient (same DATABASE_URL as the app). */ prisma: PrismaClient; /** * Service token for the internal surface (`/internal/*`), always configured * in the test app. Needed by the two-user seam (TASK-0095 S9): specs call * `POST /internal/workspaces/:id/members` with `Authorization: Bearer * ` to add a registered user without an invite mail (D3 unblock). */ internalServiceToken: string; /** * Test-only debug events token (`X-Debug-Token` for `GET * /api/v1/debug/events`) — set only in the test app (the route is 404 * unless NODE_ENV=test). Used by the E2E suite to assert stream events. */ debugToken?: string; /** * The live public v1 route table (TASK-0127 contract test ground truth): * introspects the booted Express router and returns `{ path, method }` * for every route under the `api/v1` prefix (the internal service-token * surface and the WebSocket upgrade are excluded). This is the SAME router * the production app mounts, so the spec-vs-live comparison cannot drift * from a re-scan of the sources. */ liveRoutes: () => Array<{ path: string; method: string }>; /** * Access a service from the booted production AppModule (TASK-0133 audit * completeness): drives an emitter that has no public HTTP surface for the * catalog event (e.g. `SsoProvisioningService.provision()` for * `sso.login.*`). Optional — most specs never need it. */ get?: (token: unknown) => T | null; /** Close the app + release the Prisma client (does NOT stop containers). */ close: () => Promise; } /** Locate the repo root (tests/integration/helpers → three levels up). */ function repoRoot(): string { return path.resolve(HERE, "..", "..", ".."); } /** * Run the production deployment path (`pnpm db:deploy` → * `prisma migrate deploy`) from the repo root. Falls back to the local * prisma CLI binary when pnpm is not on PATH. * * Exported so non-Nest integration specs (e.g. the worker search-index spec, * TASK-0096) can apply the schema to their own Postgres container without * booting the whole app. */ export async function deploySchema(databaseUrl: string): Promise { const root = repoRoot(); const env = { ...process.env, DATABASE_URL: databaseUrl }; try { await execFileAsync("pnpm", ["db:deploy"], { cwd: root, env, timeout: 180_000 }); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { // pnpm missing — invoke the repo-local prisma CLI (default schema // location is /prisma/schema.prisma). const cli = path.join(root, "node_modules", "prisma", "build", "index.js"); await execFileAsync(process.execPath, [cli, "migrate", "deploy"], { cwd: root, env, timeout: 180_000, }); } else { throw new Error(`prisma migrate deploy failed: ${(err as Error).message}`); } } } /** * Import the production AppModule. Deferred (dynamic) import so the * environment variables are set BEFORE Nest modules read them. * * Primary path: `@factory/api/src/app.module` — the API package exposes its * app module for testing (index re-exports AppModule; see the api package's * package.json exports). Fallback: the relative source path, which works * regardless of the exports map. */ async function loadAppModule() { try { return await import("@factory/api/src/app.module"); } catch { return await import( /* webpackIgnore: true */ pathToFileURL( path.join(repoRoot(), "apps", "api", "src", "app.module.ts"), ).href ); } } function pathToFileURL(p: string): string { return `file://${p.startsWith("/") ? p : `/${p}`}`; } /** * Create the full test app. Call once per test file; `close()` in afterAll. * Containers are shared and long-lived — release them with * stopAllContainers() (or createTestAppWithTeardown) for deterministic * port release in long CI runs. */ export async function createTestApp(): Promise { // 1) Infra. const [databaseUrl, redisUrl] = await Promise.all([ getDatabaseUrl(), getRedisUrl(), ]); // 2) Environment — canonical .env.example names, random secrets per run. process.env.NODE_ENV = "test"; process.env.DATABASE_URL = databaseUrl; process.env.REDIS_URL = redisUrl; process.env.JWT_SECRET = randomBytes(48).toString("hex"); process.env.REFRESH_TOKEN_SECRET = randomBytes(48).toString("hex"); process.env.SESSION_SECRET = randomBytes(32).toString("hex"); process.env.MASTER_ENCRYPTION_KEY = randomBytes(32).toString("base64"); // Internal surface (TASK-0092) + the two-user test seam (TASK-0095 S9): // the in-process app is the test surface, so the service-token-guarded // `/internal/*` routes are opened — including `POST // /internal/workspaces/:id/members` (the D3 seam, `INTERNAL_TEST_SEAM=1`). // A random per-run token (never a checked-in secret). const internalServiceToken = randomBytes(32).toString("hex"); process.env.INTERNAL_SERVICE_TOKEN = internalServiceToken; process.env.INTERNAL_TEST_SEAM = "1"; // Phase 10 (TASK-0109): the test-only debug events endpoint // (`GET /api/v1/debug/events`) — the E2E suite asserts domain events // through it. A random per-run token (never a checked-in secret); the // route also requires NODE_ENV=test (set above) — double-gated. process.env.TEST_DEBUG_TOKEN = randomBytes(32).toString("hex"); process.env.JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN ?? "15m"; process.env.REFRESH_TOKEN_TTL_DAYS = process.env.REFRESH_TOKEN_TTL_DAYS ?? "30"; process.env.BCRYPT_ROUNDS = process.env.BCRYPT_ROUNDS ?? "12"; process.env.PORT = "4000"; // Rate limits: a single shared test app serves ALL specs' users over one // loopback IP, and unauthenticated calls (register) key by IP — the // production defaults (auth 10/min) self-throttle the suite. Raise to // effectively-off; throttling behavior is unit-tested separately // (common/__tests__), not exercised here. process.env.RATE_LIMIT_AUTH = process.env.RATE_LIMIT_AUTH ?? "100000"; process.env.RATE_LIMIT_API = process.env.RATE_LIMIT_API ?? "100000"; // S3/Search are optional per spec; specs that need them set S3_* / // SEARCH_* before calling this (the values are read here as-is). if (process.env.S3_ENDPOINT) process.env.S3_FORCE_PATH_STYLE = "true"; // 3) Schema (production deployment path). await deploySchema(databaseUrl); // 4) App — production AppModule on an ephemeral local port. const { NestFactory } = await import("@nestjs/core"); const { RequestMethod } = await import("@nestjs/common"); const AppModule = (await loadAppModule()).AppModule; const { WEBHOOK_BODY_MAX_BYTES } = await import("@factory/shared"); const app = await NestFactory.create(AppModule, { logger: ["error", "warn"], // keep test output readable // Phase 11 (TASK-0110): match main.ts — capture the EXACT request bytes // as `req.rawBody` for the incoming-webhook HMAC signature verification // (the JSON-parsed `req.body` is re-serialized and would NOT match), // and mount the JSON parser with the 256 KB webhook limit (body-parser's // 100 KB default would wrongly 413 a valid large webhook body). rawBody: true, bodyParser: false, }); const appWithParser = app as unknown as { useBodyParser: (t: string, o: Record) => unknown; }; // T-165: mirrors main.ts — the route-scoped 2 MB parser for // POST /api/v1/databases/:id/import/csv MUST mount before the global // parsers (express runs middleware in registration order; Nest's parsers // attach at init()). const { csvImportBodyParser } = await import( /* webpackIgnore: true */ pathToFileURL( path.join(repoRoot(), "apps", "api", "src", "common", "csv-import-body.ts"), ) ); (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use( csvImportBodyParser, ); // T-167: whiteboard canvas saves (mirrors main.ts — registered before the // global JSON parser). const { whiteboardBodyParser } = await import( /* webpackIgnore: true */ pathToFileURL( path.join(repoRoot(), "apps", "api", "src", "common", "whiteboard-body.ts"), ) ); (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use( whiteboardBodyParser, ); appWithParser.useBodyParser("json", { limit: WEBHOOK_BODY_MAX_BYTES }); // TASK-0132: SCIM IdPs send `application/scim+json` (mirrors main.ts — // body-parser's default json type only matches `application/json`). appWithParser.useBodyParser("json", { type: "application/scim+json", limit: WEBHOOK_BODY_MAX_BYTES }); appWithParser.useBodyParser("urlencoded", { extended: true }); // TASK-0127: same raw-Express middleware as main.ts — deprecation headers // (`Deprecation`/`Sunset`) on the DEPRECATED_ROUTES map. const { deprecationMiddleware } = await import( /* webpackIgnore: true */ pathToFileURL( path.join(repoRoot(), "apps", "api", "src", "common", "deprecation.ts"), ) ); (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use( deprecationMiddleware, ); // Same global prefix + non-v1 exclusions as main.ts bootstrap: // public routes live under `api/v1`, the service-token surface under // bare `internal/*`, and (TASK-0131) the SSO PROTOCOL surface under // bare `saml/*` + `sso/*` (browser bindings — 302/form-POST, no JWT). // (TASK-0101: added — the helper previously booted // the module without the prefix, so every spec request 404'd.) app.setGlobalPrefix("api/v1", { exclude: [ { path: "internal/:path*", method: RequestMethod.ALL }, { path: "saml/:path*", method: RequestMethod.ALL }, { path: "sso/:path*", method: RequestMethod.ALL }, // TASK-0132: the SCIM 2.0 protocol surface (IdP binding, bearer-token // auth) — mirrors main.ts's exclude list. { path: "scim/:path*", method: RequestMethod.ALL }, ], }); // Canonical error envelope, exactly as main.ts installs it (the test app // otherwise returns Nest's default {statusCode,message} shape, not the // {error:{code,message,...}} the API contract — and specs like files // assert on `error.code`). const { AllExceptionsFilter } = await import( /* webpackIgnore: true */ pathToFileURL( path.join(repoRoot(), "apps", "api", "src", "common", "filters", "all-exceptions.filter.ts"), ) ); app.useGlobalFilters(new AllExceptionsFilter()); await app.init(); await app.listen(0, "127.0.0.1"); const addr = app.getHttpServer().address(); const baseUrl = typeof addr === "object" && addr !== null ? `http://127.0.0.1:${addr.port}` : `http://127.0.0.1:${String(addr)}`; // TASK-0127: the live public v1 route table — introspect the real Express // router (the `app.getHttpAdapter().getInstance()` IS the express app; // Nest's platform-express mounts every controller as a router layer). The // global prefix means a controller route `/workspaces` lives at express // path `/api/v1/workspaces`. We strip that prefix to get the spec path. const liveRoutes: TestAppHandle["liveRoutes"] = () => { const expressApp = app.getHttpAdapter().getInstance() as unknown as { _router: { stack: Array> }; }; const out: Array<{ path: string; method: string }> = []; const seen = new Set(); const PREFIX = "/api/v1"; const walk = (layer: Record, base: string): void => { const route = layer.route as | { path: string; methods: Record } | undefined; if (route) { for (const m of Object.keys(route.methods)) { if (!route.methods[m]) continue; let full = (base + route.path).replace(/\/{2,}/g, "/"); full = full.length > 1 ? full.replace(/\/+$/, "") : full; if (!full.startsWith(PREFIX)) continue; // internal/* + bare /health const spec = full.slice(PREFIX.length) || "/"; // Nest normalizes trailing slashes; keep the canonical form. const deduped = spec.length > 1 ? spec.replace(/\/+$/, "") : spec; const key = `${m.toUpperCase()} ${deduped}`; if (seen.has(key)) continue; seen.add(key); out.push({ path: deduped, method: m.toUpperCase() }); } return; } const handle = layer.handle as { stack?: Array> } | undefined; if (handle && Array.isArray(handle.stack)) { const prefix = (layer.regexp as { prefix?: string } | undefined)?.prefix ?? ""; for (const l2 of handle.stack) walk(l2, (base + prefix).replace(/\/{2,}/g, "/")); } }; for (const l of expressApp._router.stack) walk(l, ""); return out.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); }; const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } }); await prisma.$connect(); const request: TestAppHandle["request"] = (method, urlPath, opts) => fetch(`${baseUrl}${urlPath}`, { method, headers: { "Content-Type": "application/json", ...opts?.headers }, body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined, redirect: opts?.redirect ?? "follow", }); return { baseUrl, request, prisma, internalServiceToken, debugToken: process.env.TEST_DEBUG_TOKEN, liveRoutes, get: (token: unknown): unknown => { try { return app.get(token); } catch { return null; // service not provided (spec does not depend on it) } }, close: async () => { await prisma.$disconnect().catch(() => {}); await app.close().catch(() => {}); }, }; } /** * Convenience: full handle + guaranteed container shutdown (for runners * that do not call stopAllContainers themselves). */ export async function createTestAppWithTeardown(): Promise< TestAppHandle & { stop: () => Promise } > { const handle = await createTestApp(); const close = handle.close; return { ...handle, stop: async () => { await close(); await stopAllContainers(); }, }; }