Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3fe654a
test: add e2e webapp auth baseline and testcontainer infrastructure
matt-aitken Apr 23, 2026
7292806
Potential fix for pull request finding 'CodeQL / Insecure randomness'
matt-aitken Apr 23, 2026
b3c3f15
fix: address Devin Review bugs in webapp testcontainer
devin-ai-integration[bot] Apr 23, 2026
ef83853
test: exclude *.e2e.test.ts from standard vitest shards
matt-aitken Apr 24, 2026
121cda1
ci: add dedicated e2e webapp job that builds first then runs auth tests
matt-aitken Apr 24, 2026
8413af5
fix: use path.delimiter instead of hard-coded colon in NODE_PATH join
matt-aitken Apr 24, 2026
419a2df
fix: import vi explicitly in api-auth e2e test
matt-aitken Apr 24, 2026
9e9ff32
fix: best-effort teardown in stop() to prevent resource leaks
matt-aitken Apr 24, 2026
407a3c0
fix: add vitest.e2e.config.ts so e2e tests aren't excluded in CI
matt-aitken Apr 24, 2026
90f7f13
fix: add dummy REDIS_HOST/PORT so webapp passes module-level validati…
matt-aitken Apr 24, 2026
b58176f
fix: spin up real Redis container in startTestServer; pass host/port …
matt-aitken Apr 24, 2026
63030bf
fix: disable RUN_REPLICATION_ENABLED and WORKER_ENABLED in test webap…
matt-aitken Apr 24, 2026
acdbbb6
debug: enable WEBAPP_TEST_VERBOSE in CI; pre-warm prisma pool before …
matt-aitken Apr 24, 2026
a846e72
debug: disable all workers + pg_stat_activity diagnostics for hang in…
matt-aitken Apr 24, 2026
24399e3
fix: disable Redis TLS for test webapp — all connections were timing …
matt-aitken Apr 24, 2026
19bc016
chore: remove debug diagnostics after root cause identified and fixed
matt-aitken Apr 24, 2026
dee80ae
fix: align Redis pre-pull image tag with @testcontainers/redis defaul…
matt-aitken Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions apps/webapp/test/api-auth.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* E2E auth baseline tests.
*
* These tests capture current auth behavior before the apiBuilder migration to RBAC.
* Run them before and after the migration to verify behavior is identical.
*
* Requires a pre-built webapp: pnpm run build --filter webapp
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { TestServer } from "@internal/testcontainers/webapp";
import { startTestServer } from "@internal/testcontainers/webapp";
import { generateJWT } from "@trigger.dev/core/v3/jwt";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";

vi.setConfig({ testTimeout: 180_000 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Shared across all tests in this file — one postgres container + one webapp instance.
let server: TestServer;

beforeAll(async () => {
server = await startTestServer();
}, 180_000);

afterAll(async () => {
await server?.stop();
}, 120_000);

async function generateTestJWT(
environment: { id: string; apiKey: string },
options: { scopes?: string[] } = {}
): Promise<string> {
const scopes = options.scopes ?? ["read:runs"];
return generateJWT({
secretKey: environment.apiKey,
payload: { pub: true, sub: environment.id, scopes },
expirationTime: "15m",
});
}

describe("API bearer auth — baseline behavior", () => {
it("valid API key: auth passes (404 not 401)", async () => {
const { apiKey } = await seedTestEnvironment(server.prisma);
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
headers: { Authorization: `Bearer ${apiKey}` },
});
// Auth passed — resource just doesn't exist
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});

it("missing Authorization header: 401", async () => {
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
expect(res.status).toBe(401);
});

it("invalid API key: 401", async () => {
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
headers: { Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real" },
});
expect(res.status).toBe(401);
});

it("401 response has error field", async () => {
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
const body = await res.json();
expect(body).toHaveProperty("error");
});
});

describe("JWT bearer auth — baseline behavior", () => {
it("valid JWT on JWT-enabled route: auth passes", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });

// /api/v1/runs has allowJWT: true with superScopes: ["read:runs", ...]
const res = await server.webapp.fetch("/api/v1/runs", {
headers: { Authorization: `Bearer ${jwt}` },
});

// Auth passed — 200 (empty list) or 400 (bad search params), not 401
expect(res.status).not.toBe(401);
});

it("valid JWT on non-JWT route: 401", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });

// /api/v1/runs/$runParam/result does NOT have allowJWT: true
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
headers: { Authorization: `Bearer ${jwt}` },
});

expect(res.status).toBe(401);
});

it("JWT with empty scopes on JWT-enabled route: 403", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: [] });

const res = await server.webapp.fetch("/api/v1/runs", {
headers: { Authorization: `Bearer ${jwt}` },
});

// Empty scopes → no read:runs permission → 403
expect(res.status).toBe(403);
});

it("JWT signed with wrong key: 401", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: "wrong-signing-key-that-does-not-match-environment-key",
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
});

const res = await server.webapp.fetch("/api/v1/runs", {
headers: { Authorization: `Bearer ${jwt}` },
});

expect(res.status).toBe(401);
});
});
43 changes: 43 additions & 0 deletions apps/webapp/test/helpers/seedTestEnvironment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { PrismaClient } from "@trigger.dev/database";

function randomHex(len = 12): string {
return Math.random().toString(16).slice(2, 2 + len).padEnd(len, "0");
}

export async function seedTestEnvironment(prisma: PrismaClient) {
const suffix = randomHex(8);
const apiKey = `tr_dev_${randomHex(24)}`;

Check failure

Code scanning / CodeQL

Insecure randomness High test

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const pkApiKey = `pk_dev_${randomHex(24)}`;

Check failure

Code scanning / CodeQL

Insecure randomness High test

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const organization = await prisma.organization.create({
data: {
title: `e2e-test-org-${suffix}`,
slug: `e2e-org-${suffix}`,
v3Enabled: true,
},
});

const project = await prisma.project.create({
data: {
name: `e2e-test-project-${suffix}`,
slug: `e2e-proj-${suffix}`,
externalRef: `proj_${suffix}`,
organizationId: organization.id,
engine: "V2",
},
});

const environment = await prisma.runtimeEnvironment.create({
data: {
slug: "dev",
type: "DEVELOPMENT",
apiKey,
pkApiKey,
shortcode: suffix.slice(0, 4),
projectId: project.id,
organizationId: organization.id,
},
});

return { organization, project, environment, apiKey };
}
4 changes: 4 additions & 0 deletions internal-packages/testcontainers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./webapp": "./src/webapp.ts"
},
"dependencies": {
"@clickhouse/client": "^1.11.1",
"@opentelemetry/api": "^1.9.0",
Expand Down
2 changes: 1 addition & 1 deletion internal-packages/testcontainers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { StartedClickHouseContainer } from "./clickhouse";
import { StartedMinIOContainer, type MinIOConnectionConfig } from "./minio";
import { ClickHouseClient, createClient } from "@clickhouse/client";

export { assertNonNullable } from "./utils";
export { assertNonNullable, createPostgresContainer } from "./utils";
export { logCleanup };
export type { MinIOConnectionConfig };

Expand Down
146 changes: 146 additions & 0 deletions internal-packages/testcontainers/src/webapp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { spawn } from "child_process";
import { createServer } from "net";
import { resolve } from "path";
import { Network } from "testcontainers";
import { PrismaClient } from "@trigger.dev/database";
import { createPostgresContainer } 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, timeoutMs = 60000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (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>;
}
Comment thread
matt-aitken marked this conversation as resolved.

export async function startWebapp(databaseUrl: string): Promise<{
instance: WebappInstance;
stop: () => Promise<void>;
}> {
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}:${existingNodePath}`
: PNPM_HOISTED_MODULES;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const proc = spawn(process.execPath, ["build/server.js"], {
cwd: WEBAPP_ROOT,
env: {
...process.env,
NODE_ENV: "test",
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
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",
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);
}
});

proc.on("error", (err) => {
throw new Error(`Failed to start webapp: ${err.message}`);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

const baseUrl = `http://localhost:${port}`;

try {
await waitForHealthcheck(`${baseUrl}/healthcheck`);
} 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;
stop: () => Promise<void>;
}

/** Convenience helper: starts a postgres container + webapp and returns both for testing. */
export async function startTestServer(): Promise<TestServer> {
const network = await new Network().start();
const { url: databaseUrl, container } = await createPostgresContainer(network);

const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
const { instance: webapp, stop: stopWebapp } = await startWebapp(databaseUrl);

const stop = async () => {
await stopWebapp();
await prisma.$disconnect();
await container.stop();
await network.stop();
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

return { webapp, prisma, stop };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Loading