test: e2e auth baseline tests + webapp testcontainer infrastructure (#3438)

Adds a minimal end-to-end test harness that spawns the compiled webapp
as a child
process against a throwaway Postgres container, plus a baseline of 8
auth-behaviour
tests. These tests will be used as a regression check before and after
the upcoming
apiBuilder RBAC migration to confirm auth behaviour is unchanged.

## What's included

**`internal-packages/testcontainers/src/webapp.ts`** (new)
Spawns `build/server.js` with a dynamically allocated port, polls
`/healthcheck`,
and exposes `WebappInstance` and `startTestServer()` (postgres container
+ webapp +
PrismaClient in one call). Key details:
- Uses `process.execPath` so the correct Node binary is found in forked
test processes
- Sets `NODE_PATH` to `node_modules/.pnpm/node_modules` so pnpm-hoisted
transitive
deps (e.g. `eventsource-parser`) resolve correctly inside the subprocess
- Overrides both `PORT` and `REMIX_APP_PORT` so Vite's automatic `.env`
loading
  doesn't override the dynamically allocated port

**`internal-packages/testcontainers/package.json`**
Adds `./webapp` sub-path export so tests can `import from
"@internal/testcontainers/webapp"`.

**`internal-packages/testcontainers/src/index.ts`**
Exports `createPostgresContainer` (used internally by `webapp.ts`).

**`apps/webapp/test/helpers/seedTestEnvironment.ts`** (new)
Creates a minimal org → project → environment row set with random
suffixes.

**`apps/webapp/test/api-auth.e2e.test.ts`** (new)
8 tests across two suites:
- API-key bearer: valid key (auth passes, 404), missing header (401),
invalid key (401), error body shape
- JWT bearer: valid JWT on JWT-enabled route (passes), valid JWT on
non-JWT route (401), empty-scope JWT (403), wrong signing key (401)

## How to run

```bash
# Build required first (one-time)
pnpm run build --filter webapp

cd apps/webapp && pnpm exec vitest run test/api-auth.e2e.test.ts
```

## Test plan
- [x] All 8 tests pass against the current webapp build
- [x] Webapp healthcheck returns 200 on startup
- [ ] CI passes

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Matt Aitken
2026-04-24 12:06:35 +01:00
committed by GitHub
parent 496ac78484
commit 8aa1e55588
9 changed files with 471 additions and 1 deletions
+91
View File
@@ -0,0 +1,91 @@
name: "🧪 E2E Tests: Webapp"
permissions:
contents: read
on:
workflow_call:
jobs:
e2eTests:
name: "🧪 E2E Tests: Webapp"
runs-on: ubuntu-latest
timeout-minutes: 20
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
steps:
- name: 🔧 Disable IPv6
run: |
sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1
sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1
- name: 🔧 Configure docker address pool
run: |
CONFIG='{
"default-address-pools" : [
{
"base" : "172.17.0.0/12",
"size" : 20
},
{
"base" : "192.168.0.0/16",
"size" : 24
}
]
}'
mkdir -p /etc/docker
echo "$CONFIG" | sudo tee /etc/docker/daemon.json
- name: 🔧 Restart docker daemon
run: sudo systemctl restart docker
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
- name: 🐳 Login to DockerHub
if: ${{ env.DOCKERHUB_USERNAME }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 🐳 Skipping DockerHub login (no secrets available)
if: ${{ !env.DOCKERHUB_USERNAME }}
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
run: |
echo "Pre-pulling Docker images with authenticated session..."
docker pull postgres:14
docker pull redis:7.2
docker pull testcontainers/ryuk:0.11.0
echo "Image pre-pull complete"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🏗️ Build Webapp
run: pnpm run build --filter webapp
- name: 🧪 Run Webapp E2E Tests
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default
env:
WEBAPP_TEST_VERBOSE: "1"
+3
View File
@@ -10,6 +10,9 @@ jobs:
webapp:
uses: ./.github/workflows/unit-tests-webapp.yml
secrets: inherit
e2e-webapp:
uses: ./.github/workflows/e2e-webapp.yml
secrets: inherit
packages:
uses: ./.github/workflows/unit-tests-packages.yml
secrets: inherit
+121
View File
@@ -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, vi } 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 });
// 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);
});
});
@@ -0,0 +1,44 @@
import type { PrismaClient } from "@trigger.dev/database";
import { randomBytes } from "crypto";
function randomHex(len = 12): string {
return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
}
export async function seedTestEnvironment(prisma: PrismaClient) {
const suffix = randomHex(8);
const apiKey = `tr_dev_${randomHex(24)}`;
const pkApiKey = `pk_dev_${randomHex(24)}`;
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 };
}
+1
View File
@@ -4,6 +4,7 @@ import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
exclude: ["test/**/*.e2e.test.ts"],
globals: true,
pool: "forks",
},
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
test: {
include: ["test/**/*.e2e.test.ts"],
globals: true,
pool: "forks",
},
// @ts-ignore
plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] })],
});
@@ -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",
@@ -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 };
@@ -0,0 +1,194 @@
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, 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>;
}
export async function startWebapp(
databaseUrl: string,
redis: { host: string; port: number }
): 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}${delimiter}${existingNodePath}`
: PNPM_HOISTED_MODULES;
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",
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)
LEGACY_RUN_ENGINE_WORKER_ENABLED: "0", // disables legacy run engine worker
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",
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;
await waitForHealthcheck(`${baseUrl}/healthcheck`);
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;
stop: () => Promise<void>;
}
/** Convenience helper: starts a postgres + redis container + webapp and returns both for testing. */
export async function startTestServer(): 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 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;
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() });
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!, stop };
}