Skip to content

Commit 8aa1e55

Browse files
matt-aitkenclaudegithub-advanced-security[bot]devin-ai-integration[bot]
authored
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>
1 parent 496ac78 commit 8aa1e55

9 files changed

Lines changed: 471 additions & 1 deletion

File tree

.github/workflows/e2e-webapp.yml

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
name: "🧪 E2E Tests: Webapp"
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
workflow_call:
8+
9+
jobs:
10+
e2eTests:
11+
name: "🧪 E2E Tests: Webapp"
12+
runs-on: ubuntu-latest
13+
timeout-minutes: 20
14+
env:
15+
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
16+
steps:
17+
- name: 🔧 Disable IPv6
18+
run: |
19+
sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
20+
sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1
21+
sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1
22+
23+
- name: 🔧 Configure docker address pool
24+
run: |
25+
CONFIG='{
26+
"default-address-pools" : [
27+
{
28+
"base" : "172.17.0.0/12",
29+
"size" : 20
30+
},
31+
{
32+
"base" : "192.168.0.0/16",
33+
"size" : 24
34+
}
35+
]
36+
}'
37+
mkdir -p /etc/docker
38+
echo "$CONFIG" | sudo tee /etc/docker/daemon.json
39+
40+
- name: 🔧 Restart docker daemon
41+
run: sudo systemctl restart docker
42+
43+
- name: ⬇️ Checkout repo
44+
uses: actions/checkout@v4
45+
with:
46+
fetch-depth: 0
47+
48+
- name: ⎔ Setup pnpm
49+
uses: pnpm/action-setup@v4
50+
with:
51+
version: 10.23.0
52+
53+
- name: ⎔ Setup node
54+
uses: buildjet/setup-node@v4
55+
with:
56+
node-version: 20.20.0
57+
cache: "pnpm"
58+
59+
# ..to avoid rate limits when pulling images
60+
- name: 🐳 Login to DockerHub
61+
if: ${{ env.DOCKERHUB_USERNAME }}
62+
uses: docker/login-action@v3
63+
with:
64+
username: ${{ secrets.DOCKERHUB_USERNAME }}
65+
password: ${{ secrets.DOCKERHUB_TOKEN }}
66+
- name: 🐳 Skipping DockerHub login (no secrets available)
67+
if: ${{ !env.DOCKERHUB_USERNAME }}
68+
run: echo "DockerHub login skipped because secrets are not available."
69+
70+
- name: 🐳 Pre-pull testcontainer images
71+
if: ${{ env.DOCKERHUB_USERNAME }}
72+
run: |
73+
echo "Pre-pulling Docker images with authenticated session..."
74+
docker pull postgres:14
75+
docker pull redis:7.2
76+
docker pull testcontainers/ryuk:0.11.0
77+
echo "Image pre-pull complete"
78+
79+
- name: 📥 Download deps
80+
run: pnpm install --frozen-lockfile
81+
82+
- name: 📀 Generate Prisma Client
83+
run: pnpm run generate
84+
85+
- name: 🏗️ Build Webapp
86+
run: pnpm run build --filter webapp
87+
88+
- name: 🧪 Run Webapp E2E Tests
89+
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default
90+
env:
91+
WEBAPP_TEST_VERBOSE: "1"

.github/workflows/unit-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ jobs:
1010
webapp:
1111
uses: ./.github/workflows/unit-tests-webapp.yml
1212
secrets: inherit
13+
e2e-webapp:
14+
uses: ./.github/workflows/e2e-webapp.yml
15+
secrets: inherit
1316
packages:
1417
uses: ./.github/workflows/unit-tests-packages.yml
1518
secrets: inherit
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, vi } 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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { PrismaClient } from "@trigger.dev/database";
2+
import { randomBytes } from "crypto";
3+
4+
function randomHex(len = 12): string {
5+
return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
6+
}
7+
8+
export async function seedTestEnvironment(prisma: PrismaClient) {
9+
const suffix = randomHex(8);
10+
const apiKey = `tr_dev_${randomHex(24)}`;
11+
const pkApiKey = `pk_dev_${randomHex(24)}`;
12+
13+
const organization = await prisma.organization.create({
14+
data: {
15+
title: `e2e-test-org-${suffix}`,
16+
slug: `e2e-org-${suffix}`,
17+
v3Enabled: true,
18+
},
19+
});
20+
21+
const project = await prisma.project.create({
22+
data: {
23+
name: `e2e-test-project-${suffix}`,
24+
slug: `e2e-proj-${suffix}`,
25+
externalRef: `proj_${suffix}`,
26+
organizationId: organization.id,
27+
engine: "V2",
28+
},
29+
});
30+
31+
const environment = await prisma.runtimeEnvironment.create({
32+
data: {
33+
slug: "dev",
34+
type: "DEVELOPMENT",
35+
apiKey,
36+
pkApiKey,
37+
shortcode: suffix.slice(0, 4),
38+
projectId: project.id,
39+
organizationId: organization.id,
40+
},
41+
});
42+
43+
return { organization, project, environment, apiKey };
44+
}

apps/webapp/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import tsconfigPaths from "vite-tsconfig-paths";
44
export default defineConfig({
55
test: {
66
include: ["test/**/*.test.ts"],
7+
exclude: ["test/**/*.e2e.test.ts"],
78
globals: true,
89
pool: "forks",
910
},

apps/webapp/vitest.e2e.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from "vitest/config";
2+
import tsconfigPaths from "vite-tsconfig-paths";
3+
4+
export default defineConfig({
5+
test: {
6+
include: ["test/**/*.e2e.test.ts"],
7+
globals: true,
8+
pool: "forks",
9+
},
10+
// @ts-ignore
11+
plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] })],
12+
});

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

0 commit comments

Comments
 (0)