import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModuleBuilder } from '@nestjs/testing'; import type { NestExpressApplication } from '@nestjs/platform-express'; import cookieParser from 'cookie-parser'; import type { NextFunction, Request, Response } from 'express'; import { AppModule } from '../app.module'; import { AppConfig } from '../config/app-config.service'; /** * Suppresses the test harness's automatic `Origin` header (below) for a * single request — the way the CSRF spec simulates a client that sends * neither `Origin` nor `Referer` (issue #189). */ export const SUPPRESS_ORIGIN_HEADER = 'x-test-suppress-origin'; /** * Boots the full application for e2e tests, mirroring main.ts middleware. * Requires TEST_DATABASE_URL; DATABASE_URL is pointed at it so the app * under test uses the test database. `customize` can override providers * (e.g. inject a fake converter for the conversion queue tests, issue #62). */ export async function createTestApp( customize?: (builder: TestingModuleBuilder) => TestingModuleBuilder, ): Promise { process.env.NODE_ENV = 'test'; if (process.env.TEST_DATABASE_URL) { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; } process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent'; // Fresh scratch directory per test file so upload tests never touch the // repository or collide with each other. process.env.UPLOADS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-uploads-')); process.env.PLUGINS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-plugins-')); const base = Test.createTestingModule({ imports: [AppModule] }); const moduleRef = await (customize ? customize(base) : base).compile(); const app = moduleRef.createNestApplication(); app.use(cookieParser()); // Browsers always send `Origin` on mutations, and since #189 the CSRF // check fails closed without it. supertest simulates a browser page of // this instance, so the harness injects the matching header — absence is // simulated explicitly via SUPPRESS_ORIGIN_HEADER, never by accident. const expectedOrigin = new URL(moduleRef.get(AppConfig).env.APP_BASE_URL).origin; app.use((req: Request, _res: Response, next: NextFunction) => { if (SUPPRESS_ORIGIN_HEADER in req.headers) { delete req.headers[SUPPRESS_ORIGIN_HEADER]; delete req.headers.origin; delete req.headers.referer; } else { req.headers.origin ??= expectedOrigin; } next(); }); // Mirrors main.ts: base64 Yjs page state needs more than Express's 100kb default. app.useBodyParser('json', { limit: '8mb' }); // Mirrors main.ts: the public API (issue #104) declares its full path. app.setGlobalPrefix('api/v1', { exclude: ['api/public/v1', 'api/public/v1/{*path}', 'api/mcp'], }); await app.init(); return app; } /** Extracts the dt_session cookie pair ("name=value") from a response. */ export function sessionCookieOf(res: { headers: Record }): string { const header = res.headers['set-cookie']; const cookies = Array.isArray(header) ? header : [header].filter(Boolean); const session = (cookies as string[]).find((c) => c.startsWith('dt_session=')); if (!session) throw new Error('response carries no dt_session cookie'); return session.split(';')[0]!; }