dorfteich/apps/api/src/testing/test-app.ts
Claude Fable 5 d32c8c3730
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 1m51s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
#189: make the CSRF origin check fail closed
A cookie-carrying mutation without Origin and Referer (or with an
unparsable one) is now rejected with 403 csrf_origin_mismatch instead
of passing unchecked. The exception for non-browser clients stays
structural: PAT/bearer requests carry no session cookie and never reach
the check, and a request that does carry the cookie is always checked.

The test harness injects the matching Origin (supertest simulates a
browser page of this instance) with an explicit suppression header for
the negative cases; the Playwright fixture contexts send the header on
their manual seeding calls; release-qa.sh pins APP_BASE_URL and sends
the matching Origin. Dedicated spec covers: missing headers 403,
mismatch 403, unparsable 403, match passes, GETs untouched, PAT
mutation without headers passes, cookie+bearer still checked.

Refs #189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 09:34:44 +02:00

77 lines
3.4 KiB
TypeScript

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<INestApplication> {
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<NestExpressApplication>();
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, unknown> }): 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]!;
}