Files
Chris Arderne 6997aeb05e
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
fix: security release 2026-07-08 (#4316)
2026-07-21 12:00:58 +01:00

258 lines
10 KiB
TypeScript

import { spawn } from "child_process";
import { createServer } from "net";
import { delimiter, resolve } from "path";
import { Network } from "testcontainers";
import { PrismaClient } from "@trigger.dev/database";
import { createPostgresContainer, createRedisContainer } from "./utils";
const WEBAPP_ROOT = resolve(__dirname, "../../../apps/webapp");
// pnpm hoists transitive deps to node_modules/.pnpm/node_modules but does NOT symlink them
// to the root node_modules. We need NODE_PATH so the webapp process can find them at runtime.
const PNPM_HOISTED_MODULES = resolve(__dirname, "../../../node_modules/.pnpm/node_modules");
async function findFreePort(): Promise<number> {
return new Promise((res, rej) => {
const srv = createServer();
srv.listen(0, () => {
const port = (srv.address() as { port: number }).port;
srv.close((err) => (err ? rej(err) : res(port)));
});
});
}
async function waitForHealthcheck(
url: string,
opts: { timeoutMs?: number; acceptAnyResponse?: boolean } = {}
): Promise<void> {
const { timeoutMs = 60000, acceptAnyResponse = false } = opts;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (acceptAnyResponse || res.ok) return;
} catch {}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Webapp did not become healthy at ${url} within ${timeoutMs}ms`);
}
export interface WebappInstance {
baseUrl: string;
fetch(path: string, init?: RequestInit): Promise<Response>;
}
export interface StartWebappOptions {
/**
* When true (default), the spawned webapp runs with `RBAC_FORCE_FALLBACK=1`
* so the default fallback handles all auth checks. The comprehensive
* suite (`*.e2e.full.test.ts`) relies on this — it's pinned to the
* fallback so results don't depend on whether `@triggerdotdev/plugins/rbac`
* happens to be installed in the local node_modules.
*
* Set to false to spawn a webapp that loads any installed RBAC
* plugin instead, for testing the plugin path.
*/
forceRbacFallback?: boolean;
/**
* When true, spawns the webapp with `REQUIRE_PLUGINS=1` so the plugin
* loader throws instead of silently falling back when the plugin
* module fails to load. Used by the healthcheck rollback e2e test —
* with this set and the plugin not installed, `/healthcheck` should
* return 500.
*
* Implies `forceRbacFallback: false` (you can't observe REQUIRE_PLUGINS
* behaviour when the loader is short-circuited).
*/
requirePlugins?: boolean;
}
export async function startWebapp(
databaseUrl: string,
redis: { host: string; port: number },
options: StartWebappOptions = {}
): Promise<{
instance: WebappInstance;
stop: () => Promise<void>;
}> {
// requirePlugins implies forceRbacFallback=false — you can't observe the
// REQUIRE_PLUGINS=1 throw if the loader short-circuits before reaching it.
const requirePlugins = options.requirePlugins ?? false;
const forceRbacFallback = options.forceRbacFallback ?? !requirePlugins;
const port = await findFreePort();
// Merge NODE_PATH so transitive pnpm deps (hoisted to .pnpm/node_modules) are resolvable
const existingNodePath = process.env.NODE_PATH;
const nodePath = existingNodePath
? `${PNPM_HOISTED_MODULES}${delimiter}${existingNodePath}`
: PNPM_HOISTED_MODULES;
const proc = spawn(process.execPath, ["build/server.js"], {
cwd: WEBAPP_ROOT,
env: {
...process.env,
// Match `pnpm run start` (production-mode boot). NODE_ENV=test
// surfaces a circular-init regression in the production bundle
// — see TRI-8731 — that production-mode dodges by initialising
// modules in a different order. Tests don't depend on test-mode
// semantics; they only need an isolated webapp + DB.
NODE_ENV: "production",
DATABASE_URL: databaseUrl,
DIRECT_URL: databaseUrl,
PORT: String(port),
REMIX_APP_PORT: String(port), // override .env file value (vitest loads .env via Vite)
SESSION_SECRET: "test-session-secret-for-e2e-tests",
MAGIC_LINK_SECRET: "test-magic-link-secret-32chars!!",
ENCRYPTION_KEY: "test-encryption-key-for-e2e!!!!!", // exactly 32 bytes
// Control-plane auth secrets are required (no defaults) and reject the
// known-insecure placeholder strings, so supply strong test values here.
PROVIDER_SECRET: "test-provider-secret-for-e2e-tests",
COORDINATOR_SECRET: "test-coordinator-secret-for-e2e-tests",
MANAGED_WORKER_SECRET: "test-managed-worker-secret-for-e2e-tests",
CLICKHOUSE_URL: "http://localhost:19123", // dummy, auth paths never connect
DEPLOY_REGISTRY_HOST: "registry.example.com", // dummy, not needed for auth tests
ELECTRIC_ORIGIN: "http://localhost:3060",
REDIS_HOST: redis.host,
REDIS_PORT: String(redis.port),
REDIS_TLS_DISABLED: "true", // all *_REDIS_TLS_DISABLED vars default to this; test Redis has no TLS
// Disable all background workers. Each worker has its own env var and its own
// check idiom ("0" vs "false" vs boolean), so we set all of them explicitly.
WORKER_ENABLED: "false", // disables workerQueue.initialize() (checked === "true")
RUN_ENGINE_WORKER_ENABLED: "0", // disables run engine workers (checked === "0", default "1")
SCHEDULE_WORKER_ENABLED: "0", // disables schedule engine worker (checked === "0")
BATCH_QUEUE_WORKER_ENABLED: "false", // disables batch queue consumers (BoolEnv)
COMMON_WORKER_ENABLED: "0", // disables common worker
RUN_ENGINE_TTL_SYSTEM_DISABLED: "true", // disables TTL expiry system (BoolEnv)
RUN_ENGINE_TTL_CONSUMERS_DISABLED: "true", // disables TTL consumers (BoolEnv)
RUN_REPLICATION_ENABLED: "0",
// Force the RBAC loader to use the default fallback in e2e tests
// so auth behaviour is deterministic regardless of whether a
// plugin is installed in the local node_modules. Set to "0" /
// undefined to spawn a webapp that loads any installed plugin.
...(forceRbacFallback ? { RBAC_FORCE_FALLBACK: "1" } : {}),
// When requirePlugins is set, explicitly override RBAC_FORCE_FALLBACK
// to "0" so a local apps/webapp/.env that sets it to "1" doesn't
// short-circuit the loader past the REQUIRE_PLUGINS check.
...(requirePlugins ? { REQUIRE_PLUGINS: "1", RBAC_FORCE_FALLBACK: "0" } : {}),
NODE_PATH: nodePath,
},
stdio: ["ignore", "pipe", "pipe"],
});
const stderr: string[] = [];
proc.stderr?.on("data", (d: Buffer) => {
const line = d.toString();
stderr.push(line);
if (process.env.WEBAPP_TEST_VERBOSE) {
process.stderr.write(line);
}
});
const stdout: string[] = [];
proc.stdout?.on("data", (d: Buffer) => {
const line = d.toString();
stdout.push(line);
if (process.env.WEBAPP_TEST_VERBOSE) {
process.stdout.write(line);
}
});
// Capture spawn errors instead of throwing from inside the listener.
// A synchronous throw from an EventEmitter listener bypasses the surrounding
// try/catch and surfaces as an uncaughtException, tearing down the test runner.
let spawnError: Error | undefined;
proc.on("error", (err) => {
spawnError = err;
});
const baseUrl = `http://localhost:${port}`;
try {
if (spawnError) throw spawnError;
// When requirePlugins is set, /healthcheck is expected to respond with
// 500 (the whole point of those tests is to verify that). Accept any
// response as "the webapp is up" — the test then asserts the status.
await waitForHealthcheck(`${baseUrl}/healthcheck`, {
acceptAnyResponse: requirePlugins,
});
if (spawnError) throw spawnError;
} catch (err) {
proc.kill("SIGTERM");
const output = [...stdout, ...stderr].join("\n");
throw new Error(`Webapp failed to start.\nOutput:\n${output}\n\nOriginal error: ${err}`);
}
return {
instance: {
baseUrl,
fetch: (path: string, init?: RequestInit) => fetch(`${baseUrl}${path}`, init),
},
stop: () =>
new Promise<void>((res) => {
const timer = setTimeout(() => {
proc.kill("SIGKILL");
res();
}, 10_000);
proc.once("exit", () => {
clearTimeout(timer);
res();
});
proc.kill("SIGTERM");
}),
};
}
export interface TestServer {
webapp: WebappInstance;
prisma: PrismaClient;
// Postgres connection string. Useful when test workers run in separate
// processes and need to construct their own clients against the same DB.
databaseUrl: string;
stop: () => Promise<void>;
}
/** Convenience helper: starts a postgres + redis container + webapp and returns both for testing. */
export async function startTestServer(options: StartWebappOptions = {}): Promise<TestServer> {
const network = await new Network().start();
// Track each resource as we acquire it so we can tear it down if a later step fails.
let pgContainer: Awaited<ReturnType<typeof createPostgresContainer>>["container"] | undefined;
let pgUrl: string | undefined;
let redisContainer: Awaited<ReturnType<typeof createRedisContainer>>["container"] | undefined;
let prisma: PrismaClient | undefined;
let stopWebapp: (() => Promise<void>) | undefined;
let webapp: WebappInstance;
try {
const pg = await createPostgresContainer(network);
pgContainer = pg.container;
pgUrl = pg.url;
const { container: rc } = await createRedisContainer({ network });
redisContainer = rc;
prisma = new PrismaClient({ datasources: { db: { url: pg.url } } });
await prisma.$connect(); // pre-warm pool; surface connection failures before tests start
const started = await startWebapp(pg.url, { host: rc.getHost(), port: rc.getPort() }, options);
webapp = started.instance;
stopWebapp = started.stop;
} catch (err) {
await stopWebapp?.().catch(() => {});
await prisma?.$disconnect().catch(() => {});
await pgContainer?.stop().catch(() => {});
await redisContainer?.stop().catch(() => {});
await network.stop().catch(() => {});
throw err;
}
const stop = async () => {
await stopWebapp!().catch((err) => console.error("stopWebapp failed:", err));
await prisma!.$disconnect().catch((err) => console.error("prisma.$disconnect failed:", err));
await pgContainer!.stop().catch((err) => console.error("pgContainer.stop failed:", err));
await redisContainer!.stop().catch((err) => console.error("redisContainer.stop failed:", err));
await network.stop().catch((err) => console.error("network.stop failed:", err));
};
return { webapp, prisma: prisma!, databaseUrl: pgUrl!, stop };
}