1
28import { randomBytes } from "node:crypto";
29import { execFile } from "node:child_process";
30import { promisify } from "node:util";
31import * as path from "node:path";
32import { fileURLToPath } from "node:url";
33import { PrismaClient } from "@prisma/client";
34import { getDatabaseUrl, getRedisUrl, stopAllContainers } from "./containers";
35
36const execFileAsync = promisify(execFile);
37const HERE = path.dirname(fileURLToPath(import.meta.url));
38
39export interface TestAppHandle {
40
41 baseUrl: string;
42
45 request: (
46 method: string,
47 urlPath: string,
48 opts?: { body?: unknown; headers?: Record<string, string>; redirect?: RequestRedirect },
49 ) => Promise<Response>;
50
51 prisma: PrismaClient;
52
58 internalServiceToken: string;
59
64 debugToken?: string;
65
73 liveRoutes: () => Array<{ path: string; method: string }>;
74
80 get?: <T>(token: unknown) => T | null;
81
82 close: () => Promise<void>;
83}
84
85
86function repoRoot(): string {
87 return path.resolve(HERE, "..", "..", "..");
88}
89
90
99export async function deploySchema(databaseUrl: string): Promise<void> {
100 const root = repoRoot();
101 const env = { ...process.env, DATABASE_URL: databaseUrl };
102 try {
103 await execFileAsync("pnpm", ["db:deploy"], { cwd: root, env, timeout: 180_000 });
104 } catch (err) {
105 if ((err as NodeJS.ErrnoException).code === "ENOENT") {
106
107
108 const cli = path.join(root, "node_modules", "prisma", "build", "index.js");
109 await execFileAsync(process.execPath, [cli, "migrate", "deploy"], {
110 cwd: root,
111 env,
112 timeout: 180_000,
113 });
114 } else {
115 throw new Error(`prisma migrate deploy failed: ${(err as Error).message}`);
116 }
117 }
118}
119
120
129async function loadAppModule() {
130 try {
131 return await import("@factory/api/src/app.module");
132 } catch {
133 return await import(
134 pathToFileURL(
135 path.join(repoRoot(), "apps", "api", "src", "app.module.ts"),
136 ).href
137 );
138 }
139}
140
141function pathToFileURL(p: string): string {
142 return `file://${p.startsWith("/") ? p : `/${p}`}`;
143}
144
145
151export async function createTestApp(): Promise<TestAppHandle> {
152
153 const [databaseUrl, redisUrl] = await Promise.all([
154 getDatabaseUrl(),
155 getRedisUrl(),
156 ]);
157
158
159 process.env.NODE_ENV = "test";
160 process.env.DATABASE_URL = databaseUrl;
161 process.env.REDIS_URL = redisUrl;
162 process.env.JWT_SECRET = randomBytes(48).toString("hex");
163 process.env.REFRESH_TOKEN_SECRET = randomBytes(48).toString("hex");
164 process.env.SESSION_SECRET = randomBytes(32).toString("hex");
165 process.env.MASTER_ENCRYPTION_KEY = randomBytes(32).toString("base64");
166
167
168
169
170
171 const internalServiceToken = randomBytes(32).toString("hex");
172 process.env.INTERNAL_SERVICE_TOKEN = internalServiceToken;
173 process.env.INTERNAL_TEST_SEAM = "1";
174
175
176
177
178 process.env.TEST_DEBUG_TOKEN = randomBytes(32).toString("hex");
179 process.env.JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN ?? "15m";
180 process.env.REFRESH_TOKEN_TTL_DAYS = process.env.REFRESH_TOKEN_TTL_DAYS ?? "30";
181 process.env.BCRYPT_ROUNDS = process.env.BCRYPT_ROUNDS ?? "12";
182 process.env.PORT = "4000";
183
184
185
186
187
188 process.env.RATE_LIMIT_AUTH = process.env.RATE_LIMIT_AUTH ?? "100000";
189 process.env.RATE_LIMIT_API = process.env.RATE_LIMIT_API ?? "100000";
190
191
192 if (process.env.S3_ENDPOINT) process.env.S3_FORCE_PATH_STYLE = "true";
193
194
195 await deploySchema(databaseUrl);
196
197
198 const { NestFactory } = await import("@nestjs/core");
199 const { RequestMethod } = await import("@nestjs/common");
200 const AppModule = (await loadAppModule()).AppModule;
201 const { WEBHOOK_BODY_MAX_BYTES } = await import("@factory/shared");
202 const app = await NestFactory.create(AppModule, {
203 logger: ["error", "warn"],
204
205
206
207
208
209 rawBody: true,
210 bodyParser: false,
211 });
212 const appWithParser = app as unknown as {
213 useBodyParser: (t: string, o: Record<string, unknown>) => unknown;
214 };
215
216
217
218
219 const { csvImportBodyParser } = await import(
220 pathToFileURL(
221 path.join(repoRoot(), "apps", "api", "src", "common", "csv-import-body.ts"),
222 )
223 );
224 (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use(
225 csvImportBodyParser,
226 );
227
228
229 const { whiteboardBodyParser } = await import(
230 pathToFileURL(
231 path.join(repoRoot(), "apps", "api", "src", "common", "whiteboard-body.ts"),
232 )
233 );
234 (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use(
235 whiteboardBodyParser,
236 );
237 appWithParser.useBodyParser("json", { limit: WEBHOOK_BODY_MAX_BYTES });
238
239
240 appWithParser.useBodyParser("json", { type: "application/scim+json", limit: WEBHOOK_BODY_MAX_BYTES });
241 appWithParser.useBodyParser("urlencoded", { extended: true });
242
243
244 const { deprecationMiddleware } = await import(
245 pathToFileURL(
246 path.join(repoRoot(), "apps", "api", "src", "common", "deprecation.ts"),
247 )
248 );
249 (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use(
250 deprecationMiddleware,
251 );
252
253
254
255
256
257
258 app.setGlobalPrefix("api/v1", {
259 exclude: [
260 { path: "internal/:path*", method: RequestMethod.ALL },
261 { path: "saml/:path*", method: RequestMethod.ALL },
262 { path: "sso/:path*", method: RequestMethod.ALL },
263
264
265 { path: "scim/:path*", method: RequestMethod.ALL },
266 ],
267 });
268
269
270
271
272 const { AllExceptionsFilter } = await import(
273 pathToFileURL(
274 path.join(repoRoot(), "apps", "api", "src", "common", "filters", "all-exceptions.filter.ts"),
275 )
276 );
277 app.useGlobalFilters(new AllExceptionsFilter());
278 await app.init();
279 await app.listen(0, "127.0.0.1");
280 const addr = app.getHttpServer().address();
281 const baseUrl =
282 typeof addr === "object" && addr !== null
283 ? `http://127.0.0.1:${addr.port}`
284 : `http://127.0.0.1:${String(addr)}`;
285
286
287
288
289
290
291 const liveRoutes: TestAppHandle["liveRoutes"] = () => {
292 const expressApp = app.getHttpAdapter().getInstance() as unknown as {
293 _router: { stack: Array<Record<string, unknown>> };
294 };
295 const out: Array<{ path: string; method: string }> = [];
296 const seen = new Set<string>();
297 const PREFIX = "/api/v1";
298 const walk = (layer: Record<string, unknown>, base: string): void => {
299 const route = layer.route as
300 | { path: string; methods: Record<string, boolean> }
301 | undefined;
302 if (route) {
303 for (const m of Object.keys(route.methods)) {
304 if (!route.methods[m]) continue;
305 let full = (base + route.path).replace(/\/{2,}/g, "/");
306 full = full.length > 1 ? full.replace(/\/+$/, "") : full;
307 if (!full.startsWith(PREFIX)) continue;
308 const spec = full.slice(PREFIX.length) || "/";
309
310 const deduped = spec.length > 1 ? spec.replace(/\/+$/, "") : spec;
311 const key = `${m.toUpperCase()} ${deduped}`;
312 if (seen.has(key)) continue;
313 seen.add(key);
314 out.push({ path: deduped, method: m.toUpperCase() });
315 }
316 return;
317 }
318 const handle = layer.handle as { stack?: Array<Record<string, unknown>> } | undefined;
319 if (handle && Array.isArray(handle.stack)) {
320 const prefix = (layer.regexp as { prefix?: string } | undefined)?.prefix ?? "";
321 for (const l2 of handle.stack) walk(l2, (base + prefix).replace(/\/{2,}/g, "/"));
322 }
323 };
324 for (const l of expressApp._router.stack) walk(l, "");
325 return out.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
326 };
327
328 const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
329 await prisma.$connect();
330
331 const request: TestAppHandle["request"] = (method, urlPath, opts) =>
332 fetch(`${baseUrl}${urlPath}`, {
333 method,
334 headers: { "Content-Type": "application/json", ...opts?.headers },
335 body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined,
336 redirect: opts?.redirect ?? "follow",
337 });
338
339 return {
340 baseUrl,
341 request,
342 prisma,
343 internalServiceToken,
344 debugToken: process.env.TEST_DEBUG_TOKEN,
345 liveRoutes,
346 get: (token: unknown): unknown => {
347 try {
348 return app.get(token);
349 } catch {
350 return null;
351 }
352 },
353 close: async () => {
354 await prisma.$disconnect().catch(() => {});
355 await app.close().catch(() => {});
356 },
357 };
358}
359
360
364export async function createTestAppWithTeardown(): Promise<
365 TestAppHandle & { stop: () => Promise<void> }
366> {
367 const handle = await createTestApp();
368 const close = handle.close;
369 return {
370 ...handle,
371 stop: async () => {
372 await close();
373 await stopAllContainers();
374 },
375 };
376}
377