Skip to content

Commit 3fe654a

Browse files
matt-aitkenclaude
andcommitted
test: add e2e webapp auth baseline and testcontainer infrastructure
Adds the ability to spawn the built webapp as a child process in tests, plus a baseline of auth behaviour tests that will be used to verify the upcoming apiBuilder RBAC migration leaves auth behaviour unchanged. - internal-packages/testcontainers/src/webapp.ts: new helper that spawns build/server.js, waits for /healthcheck, and exposes a WebappInstance + startTestServer() convenience wrapper - internal-packages/testcontainers/package.json: add ./webapp sub-path export so tests can import from @internal/testcontainers/webapp - internal-packages/testcontainers/src/index.ts: export createPostgresContainer (needed by webapp.ts internally) - apps/webapp/test/helpers/seedTestEnvironment.ts: creates a minimal org/project/environment row set for use in auth tests - apps/webapp/test/api-auth.e2e.test.ts: 8 baseline tests covering API-key auth, JWT auth, missing/invalid credentials, and error shapes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7d7ebdd commit 3fe654a

5 files changed

Lines changed: 315 additions & 1 deletion

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* E2E auth baseline tests.
3+
*
4+
* These tests capture current auth behavior before the apiBuilder migration to RBAC.
5+
* Run them before and after the migration to verify behavior is identical.
6+
*
7+
* Requires a pre-built webapp: pnpm run build --filter webapp
8+
*/
9+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
10+
import type { TestServer } from "@internal/testcontainers/webapp";
11+
import { startTestServer } from "@internal/testcontainers/webapp";
12+
import { generateJWT } from "@trigger.dev/core/v3/jwt";
13+
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
14+
15+
vi.setConfig({ testTimeout: 180_000 });
16+
17+
// Shared across all tests in this file — one postgres container + one webapp instance.
18+
let server: TestServer;
19+
20+
beforeAll(async () => {
21+
server = await startTestServer();
22+
}, 180_000);
23+
24+
afterAll(async () => {
25+
await server?.stop();
26+
}, 120_000);
27+
28+
async function generateTestJWT(
29+
environment: { id: string; apiKey: string },
30+
options: { scopes?: string[] } = {}
31+
): Promise<string> {
32+
const scopes = options.scopes ?? ["read:runs"];
33+
return generateJWT({
34+
secretKey: environment.apiKey,
35+
payload: { pub: true, sub: environment.id, scopes },
36+
expirationTime: "15m",
37+
});
38+
}
39+
40+
describe("API bearer auth — baseline behavior", () => {
41+
it("valid API key: auth passes (404 not 401)", async () => {
42+
const { apiKey } = await seedTestEnvironment(server.prisma);
43+
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
44+
headers: { Authorization: `Bearer ${apiKey}` },
45+
});
46+
// Auth passed — resource just doesn't exist
47+
expect(res.status).not.toBe(401);
48+
expect(res.status).not.toBe(403);
49+
});
50+
51+
it("missing Authorization header: 401", async () => {
52+
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
53+
expect(res.status).toBe(401);
54+
});
55+
56+
it("invalid API key: 401", async () => {
57+
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
58+
headers: { Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real" },
59+
});
60+
expect(res.status).toBe(401);
61+
});
62+
63+
it("401 response has error field", async () => {
64+
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
65+
const body = await res.json();
66+
expect(body).toHaveProperty("error");
67+
});
68+
});
69+
70+
describe("JWT bearer auth — baseline behavior", () => {
71+
it("valid JWT on JWT-enabled route: auth passes", async () => {
72+
const { environment } = await seedTestEnvironment(server.prisma);
73+
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
74+
75+
// /api/v1/runs has allowJWT: true with superScopes: ["read:runs", ...]
76+
const res = await server.webapp.fetch("/api/v1/runs", {
77+
headers: { Authorization: `Bearer ${jwt}` },
78+
});
79+
80+
// Auth passed — 200 (empty list) or 400 (bad search params), not 401
81+
expect(res.status).not.toBe(401);
82+
});
83+
84+
it("valid JWT on non-JWT route: 401", async () => {
85+
const { environment } = await seedTestEnvironment(server.prisma);
86+
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
87+
88+
// /api/v1/runs/$runParam/result does NOT have allowJWT: true
89+
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
90+
headers: { Authorization: `Bearer ${jwt}` },
91+
});
92+
93+
expect(res.status).toBe(401);
94+
});
95+
96+
it("JWT with empty scopes on JWT-enabled route: 403", async () => {
97+
const { environment } = await seedTestEnvironment(server.prisma);
98+
const jwt = await generateTestJWT(environment, { scopes: [] });
99+
100+
const res = await server.webapp.fetch("/api/v1/runs", {
101+
headers: { Authorization: `Bearer ${jwt}` },
102+
});
103+
104+
// Empty scopes → no read:runs permission → 403
105+
expect(res.status).toBe(403);
106+
});
107+
108+
it("JWT signed with wrong key: 401", async () => {
109+
const { environment } = await seedTestEnvironment(server.prisma);
110+
const jwt = await generateJWT({
111+
secretKey: "wrong-signing-key-that-does-not-match-environment-key",
112+
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
113+
});
114+
115+
const res = await server.webapp.fetch("/api/v1/runs", {
116+
headers: { Authorization: `Bearer ${jwt}` },
117+
});
118+
119+
expect(res.status).toBe(401);
120+
});
121+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { PrismaClient } from "@trigger.dev/database";
2+
3+
function randomHex(len = 12): string {
4+
return Math.random().toString(16).slice(2, 2 + len).padEnd(len, "0");
5+
}
6+
7+
export async function seedTestEnvironment(prisma: PrismaClient) {
8+
const suffix = randomHex(8);
9+
const apiKey = `tr_dev_${randomHex(24)}`;
10+
const pkApiKey = `pk_dev_${randomHex(24)}`;
11+
12+
const organization = await prisma.organization.create({
13+
data: {
14+
title: `e2e-test-org-${suffix}`,
15+
slug: `e2e-org-${suffix}`,
16+
v3Enabled: true,
17+
},
18+
});
19+
20+
const project = await prisma.project.create({
21+
data: {
22+
name: `e2e-test-project-${suffix}`,
23+
slug: `e2e-proj-${suffix}`,
24+
externalRef: `proj_${suffix}`,
25+
organizationId: organization.id,
26+
engine: "V2",
27+
},
28+
});
29+
30+
const environment = await prisma.runtimeEnvironment.create({
31+
data: {
32+
slug: "dev",
33+
type: "DEVELOPMENT",
34+
apiKey,
35+
pkApiKey,
36+
shortcode: suffix.slice(0, 4),
37+
projectId: project.id,
38+
organizationId: organization.id,
39+
},
40+
});
41+
42+
return { organization, project, environment, apiKey };
43+
}

internal-packages/testcontainers/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
"version": "0.0.1",
55
"main": "./src/index.ts",
66
"types": "./src/index.ts",
7+
"exports": {
8+
".": "./src/index.ts",
9+
"./webapp": "./src/webapp.ts"
10+
},
711
"dependencies": {
812
"@clickhouse/client": "^1.11.1",
913
"@opentelemetry/api": "^1.9.0",

internal-packages/testcontainers/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { StartedClickHouseContainer } from "./clickhouse";
1818
import { StartedMinIOContainer, type MinIOConnectionConfig } from "./minio";
1919
import { ClickHouseClient, createClient } from "@clickhouse/client";
2020

21-
export { assertNonNullable } from "./utils";
21+
export { assertNonNullable, createPostgresContainer } from "./utils";
2222
export { logCleanup };
2323
export type { MinIOConnectionConfig };
2424

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { spawn } from "child_process";
2+
import { createServer } from "net";
3+
import { resolve } from "path";
4+
import { Network } from "testcontainers";
5+
import { PrismaClient } from "@trigger.dev/database";
6+
import { createPostgresContainer } from "./utils";
7+
8+
const WEBAPP_ROOT = resolve(__dirname, "../../../apps/webapp");
9+
// pnpm hoists transitive deps to node_modules/.pnpm/node_modules but does NOT symlink them
10+
// to the root node_modules. We need NODE_PATH so the webapp process can find them at runtime.
11+
const PNPM_HOISTED_MODULES = resolve(__dirname, "../../../node_modules/.pnpm/node_modules");
12+
13+
async function findFreePort(): Promise<number> {
14+
return new Promise((res, rej) => {
15+
const srv = createServer();
16+
srv.listen(0, () => {
17+
const port = (srv.address() as { port: number }).port;
18+
srv.close((err) => (err ? rej(err) : res(port)));
19+
});
20+
});
21+
}
22+
23+
async function waitForHealthcheck(url: string, timeoutMs = 60000): Promise<void> {
24+
const deadline = Date.now() + timeoutMs;
25+
while (Date.now() < deadline) {
26+
try {
27+
const res = await fetch(url);
28+
if (res.ok) return;
29+
} catch {}
30+
await new Promise((r) => setTimeout(r, 500));
31+
}
32+
throw new Error(`Webapp did not become healthy at ${url} within ${timeoutMs}ms`);
33+
}
34+
35+
export interface WebappInstance {
36+
baseUrl: string;
37+
fetch(path: string, init?: RequestInit): Promise<Response>;
38+
}
39+
40+
export async function startWebapp(databaseUrl: string): Promise<{
41+
instance: WebappInstance;
42+
stop: () => Promise<void>;
43+
}> {
44+
const port = await findFreePort();
45+
46+
// Merge NODE_PATH so transitive pnpm deps (hoisted to .pnpm/node_modules) are resolvable
47+
const existingNodePath = process.env.NODE_PATH;
48+
const nodePath = existingNodePath
49+
? `${PNPM_HOISTED_MODULES}:${existingNodePath}`
50+
: PNPM_HOISTED_MODULES;
51+
52+
const proc = spawn(process.execPath, ["build/server.js"], {
53+
cwd: WEBAPP_ROOT,
54+
env: {
55+
...process.env,
56+
NODE_ENV: "test",
57+
DATABASE_URL: databaseUrl,
58+
DIRECT_URL: databaseUrl,
59+
PORT: String(port),
60+
REMIX_APP_PORT: String(port), // override .env file value (vitest loads .env via Vite)
61+
SESSION_SECRET: "test-session-secret-for-e2e-tests",
62+
MAGIC_LINK_SECRET: "test-magic-link-secret-32chars!!",
63+
ENCRYPTION_KEY: "test-encryption-key-for-e2e!!!!!", // exactly 32 bytes
64+
CLICKHOUSE_URL: "http://localhost:19123", // dummy, auth paths never connect
65+
DEPLOY_REGISTRY_HOST: "registry.example.com", // dummy, not needed for auth tests
66+
ELECTRIC_ORIGIN: "http://localhost:3060",
67+
NODE_PATH: nodePath,
68+
},
69+
stdio: ["ignore", "pipe", "pipe"],
70+
});
71+
72+
const stderr: string[] = [];
73+
proc.stderr?.on("data", (d: Buffer) => {
74+
const line = d.toString();
75+
stderr.push(line);
76+
if (process.env.WEBAPP_TEST_VERBOSE) {
77+
process.stderr.write(line);
78+
}
79+
});
80+
81+
const stdout: string[] = [];
82+
proc.stdout?.on("data", (d: Buffer) => {
83+
const line = d.toString();
84+
stdout.push(line);
85+
if (process.env.WEBAPP_TEST_VERBOSE) {
86+
process.stdout.write(line);
87+
}
88+
});
89+
90+
proc.on("error", (err) => {
91+
throw new Error(`Failed to start webapp: ${err.message}`);
92+
});
93+
94+
const baseUrl = `http://localhost:${port}`;
95+
96+
try {
97+
await waitForHealthcheck(`${baseUrl}/healthcheck`);
98+
} catch (err) {
99+
proc.kill("SIGTERM");
100+
const output = [...stdout, ...stderr].join("\n");
101+
throw new Error(`Webapp failed to start.\nOutput:\n${output}\n\nOriginal error: ${err}`);
102+
}
103+
104+
return {
105+
instance: {
106+
baseUrl,
107+
fetch: (path: string, init?: RequestInit) => fetch(`${baseUrl}${path}`, init),
108+
},
109+
stop: () =>
110+
new Promise<void>((res) => {
111+
const timer = setTimeout(() => {
112+
proc.kill("SIGKILL");
113+
res();
114+
}, 10_000);
115+
proc.once("exit", () => {
116+
clearTimeout(timer);
117+
res();
118+
});
119+
proc.kill("SIGTERM");
120+
}),
121+
};
122+
}
123+
124+
export interface TestServer {
125+
webapp: WebappInstance;
126+
prisma: PrismaClient;
127+
stop: () => Promise<void>;
128+
}
129+
130+
/** Convenience helper: starts a postgres container + webapp and returns both for testing. */
131+
export async function startTestServer(): Promise<TestServer> {
132+
const network = await new Network().start();
133+
const { url: databaseUrl, container } = await createPostgresContainer(network);
134+
135+
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
136+
const { instance: webapp, stop: stopWebapp } = await startWebapp(databaseUrl);
137+
138+
const stop = async () => {
139+
await stopWebapp();
140+
await prisma.$disconnect();
141+
await container.stop();
142+
await network.stop();
143+
};
144+
145+
return { webapp, prisma, stop };
146+
}

0 commit comments

Comments
 (0)