Skip to content

Commit bb5079e

Browse files
committed
RBAC tests: shared-container test harness for the comprehensive auth suite (TRI-8732)
Foundation for TRI-8731. The smoke api-auth.e2e.test.ts spins up its own webapp + Postgres container per test file (~30s startup each). The comprehensive matrix would have 12+ files, so per-file startup would dominate runtime. Instead this harness boots one container for the whole suite and rapid-fires tests across multiple files. Layout: - vitest.e2e.full.config.ts — globalSetup + pool: forks. Picks up test/**/*.e2e.full.test.ts. - test/setup/global-e2e-full-setup.ts — calls startTestServer() once, provides baseUrl + databaseUrl to test workers via vitest's provide()/inject() API. Tears down on suite end. - test/helpers/sharedTestServer.ts — getTestServer() pulls the provided values, constructs a per-worker PrismaClient, exposes { webapp, prisma } matching the existing TestServer shape. - test/helpers/seedTestSession.ts — produces a Cookie header value compatible with the webapp's createCookieSessionStorage config so dashboard tests (TRI-8742) can authenticate as a seeded user. - test/auth-api.e2e.full.test.ts, test/auth-dashboard.e2e.full.test.ts, test/auth-cross-cutting.e2e.full.test.ts — three file shells with top-level describe blocks. Family subtasks (TRI-8733+) add nested describes inside. - .github/workflows/e2e-webapp-auth-full.yml — workflow_dispatch + nightly schedule + pull_request paths-filtered (only triggers on PRs touching auth-relevant files). - test/README.md — documents the unit / smoke-e2e / full-e2e split. Touching @internal/testcontainers: - TestServer interface gains databaseUrl so per-worker PrismaClient reconstruction has the connection string without going through the serialised prisma instance (which can't cross worker boundaries). - utils.ts — assertNonNullable's vitest import was previously eager at module load. globalSetup runs outside any vitest worker, so that eager init crashed (createExpect needs worker state). Switched to a lazy require('vitest') inside the function body. The function still runs in test workers where worker state exists. - logs.ts — TaskContext changed to type-only import for the same module-load-time concern (transitively imported by webapp.ts). Verification: - pnpm run typecheck across @internal/testcontainers + webapp — clean. - pnpm exec vitest run --config vitest.e2e.full.config.ts — 3/3 tests pass in 19.37s with one observed container startup. Subsequent family subtasks add describes with no per-file container cost. The placeholder it() in each file (just hits /healthcheck or counts users) gets removed by the family subtasks as they add real coverage.
1 parent 5ed8cdb commit bb5079e

11 files changed

Lines changed: 409 additions & 2 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
name: "🛡️ E2E Tests: Webapp Auth (full)"
2+
3+
# Comprehensive RBAC auth test suite — see TRI-8731. Runs separately from
4+
# the smoke e2e-webapp.yml because it covers every route family with a
5+
# pass/fail matrix and would otherwise dominate per-PR CI time.
6+
#
7+
# Triggered:
8+
# - Manually via workflow_dispatch.
9+
# - Nightly via schedule.
10+
# - On pull requests touching auth-relevant files only (paths filter).
11+
12+
permissions:
13+
contents: read
14+
15+
on:
16+
workflow_dispatch:
17+
schedule:
18+
- cron: "0 4 * * *" # 04:00 UTC daily
19+
pull_request:
20+
paths:
21+
- "apps/webapp/app/services/routeBuilders/**"
22+
- "apps/webapp/app/services/rbac.server.ts"
23+
- "apps/webapp/app/services/apiAuth.server.ts"
24+
- "apps/webapp/app/services/personalAccessToken.server.ts"
25+
- "apps/webapp/app/services/sessionStorage.server.ts"
26+
- "apps/webapp/app/routes/api.v*.**"
27+
- "apps/webapp/app/routes/realtime.v*.**"
28+
- "apps/webapp/test/**/*.e2e.full.test.ts"
29+
- "apps/webapp/test/setup/global-e2e-full-setup.ts"
30+
- "apps/webapp/test/helpers/sharedTestServer.ts"
31+
- "apps/webapp/test/helpers/seedTestSession.ts"
32+
- "apps/webapp/vitest.e2e.full.config.ts"
33+
- "internal-packages/rbac/**"
34+
- "packages/plugins/**"
35+
- ".github/workflows/e2e-webapp-auth-full.yml"
36+
37+
jobs:
38+
e2eAuthFull:
39+
name: "🛡️ E2E Auth Tests (full)"
40+
runs-on: ubuntu-latest
41+
timeout-minutes: 30
42+
env:
43+
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
44+
steps:
45+
- name: 🔧 Disable IPv6
46+
run: |
47+
sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
48+
sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1
49+
sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1
50+
51+
- name: 🔧 Configure docker address pool
52+
run: |
53+
CONFIG='{
54+
"default-address-pools" : [
55+
{
56+
"base" : "172.17.0.0/12",
57+
"size" : 20
58+
},
59+
{
60+
"base" : "192.168.0.0/16",
61+
"size" : 24
62+
}
63+
]
64+
}'
65+
mkdir -p /etc/docker
66+
echo "$CONFIG" | sudo tee /etc/docker/daemon.json
67+
68+
- name: 🔧 Restart docker daemon
69+
run: sudo systemctl restart docker
70+
71+
- name: ⬇️ Checkout repo
72+
uses: actions/checkout@v4
73+
with:
74+
fetch-depth: 0
75+
76+
- name: ⎔ Setup pnpm
77+
uses: pnpm/action-setup@v4
78+
with:
79+
version: 10.23.0
80+
81+
- name: ⎔ Setup node
82+
uses: buildjet/setup-node@v4
83+
with:
84+
node-version: 20.20.0
85+
cache: "pnpm"
86+
87+
- name: 🐳 Login to DockerHub
88+
if: ${{ env.DOCKERHUB_USERNAME }}
89+
uses: docker/login-action@v3
90+
with:
91+
username: ${{ secrets.DOCKERHUB_USERNAME }}
92+
password: ${{ secrets.DOCKERHUB_TOKEN }}
93+
- name: 🐳 Skipping DockerHub login (no secrets available)
94+
if: ${{ !env.DOCKERHUB_USERNAME }}
95+
run: echo "DockerHub login skipped because secrets are not available."
96+
97+
- name: 🐳 Pre-pull testcontainer images
98+
if: ${{ env.DOCKERHUB_USERNAME }}
99+
run: |
100+
docker pull postgres:14
101+
docker pull redis:7.2
102+
docker pull testcontainers/ryuk:0.11.0
103+
104+
- name: 📥 Download deps
105+
run: pnpm install --frozen-lockfile
106+
107+
- name: 📀 Generate Prisma Client
108+
run: pnpm run generate
109+
110+
- name: 🏗️ Build Webapp
111+
run: pnpm run build --filter webapp
112+
113+
- name: 🛡️ Run Webapp Full Auth E2E Tests
114+
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.full.config.ts --reporter=default
115+
env:
116+
WEBAPP_TEST_VERBOSE: "1"

apps/webapp/test/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Webapp tests
2+
3+
Three suites live in this directory.
4+
5+
## Unit tests — `*.test.ts`
6+
7+
Run with `pnpm test` from `apps/webapp`. Default vitest pickup. No
8+
container setup. Run on every PR via `unit-tests-webapp.yml`.
9+
10+
## Smoke e2e — `*.e2e.test.ts`
11+
12+
End-to-end auth baseline that proves the route auth plumbing is wired up.
13+
Each file spins up its own webapp + Postgres + Redis container in
14+
`beforeAll` (~30s startup). Vitest config: `vitest.e2e.config.ts`. Run on
15+
every PR via `e2e-webapp.yml`.
16+
17+
```bash
18+
cd apps/webapp
19+
pnpm exec vitest --config vitest.e2e.config.ts
20+
```
21+
22+
## Comprehensive auth e2e — `*.e2e.full.test.ts`
23+
24+
The full RBAC auth matrix — every route family with explicit pass/fail
25+
scenarios. See TRI-8731 for the parent ticket and TRI-8732 onwards for
26+
each family's coverage spec.
27+
28+
**Architecture**: one container reused across the whole suite via
29+
`vitest.e2e.full.config.ts`'s `globalSetup`. Test files share the server
30+
through `getTestServer()` from `helpers/sharedTestServer.ts`. Each test
31+
seeds its own resources so order doesn't matter.
32+
33+
**Layout**:
34+
35+
| File | Top-level describe | Family subtasks |
36+
|---|---|---|
37+
| `auth-api.e2e.full.test.ts` | `API` | TRI-8733 trigger, TRI-8734 run resource, TRI-8735 run mutations, TRI-8736 run lists, TRI-8737 batches, TRI-8738 prompts, TRI-8739 deployments + query, TRI-8740 waitpoints + input streams, TRI-8741 PAT |
38+
| `auth-dashboard.e2e.full.test.ts` | `Dashboard` | TRI-8742 admin pages |
39+
| `auth-cross-cutting.e2e.full.test.ts` | `Cross-cutting` | TRI-8743 deleted projects / revoked keys / expired JWTs / env mismatch / force-fallback toggle |
40+
41+
**Adding a new family**: pick the relevant file, add a nested `describe`
42+
block. Inside, seed your own fixtures via the helpers and hit the shared
43+
server.
44+
45+
```ts
46+
describe("Trigger task", () => {
47+
const server = getTestServer();
48+
49+
it("missing Authorization → 401", async () => {
50+
const res = await server.webapp.fetch("/api/v1/tasks/x/trigger", { method: "POST", body: "{}" });
51+
expect(res.status).toBe(401);
52+
});
53+
});
54+
```
55+
56+
**CI**: `e2e-webapp-auth-full.yml`. Triggers on `workflow_dispatch`,
57+
nightly schedule, and PRs touching auth-relevant paths (route builders,
58+
rbac.server.ts, apiAuth.server.ts, apiroutes, the suite itself).
59+
60+
**Run locally**:
61+
62+
```bash
63+
cd apps/webapp
64+
pnpm exec vitest --config vitest.e2e.full.config.ts
65+
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Comprehensive API auth tests — uses the shared TestServer started by
2+
// vitest.e2e.full.config.ts's globalSetup. Family subtasks under TRI-8731
3+
// add nested describe blocks here:
4+
//
5+
// describe("API", () => {
6+
// describe("Trigger task", () => { ... }) // TRI-8733
7+
// describe("Runs — resource routes", () => { ... }) // TRI-8734
8+
// ...
9+
// })
10+
//
11+
// See test/helpers/sharedTestServer.ts for `getTestServer()`.
12+
13+
import { describe, expect, it } from "vitest";
14+
import { getTestServer } from "./helpers/sharedTestServer";
15+
16+
describe("API", () => {
17+
// Placeholder until family subtasks add their describes (TRI-8733+).
18+
// Verifies the shared container is reachable from this worker.
19+
it("shared webapp container responds to /healthcheck", async () => {
20+
const server = getTestServer();
21+
const res = await server.webapp.fetch("/healthcheck");
22+
expect(res.ok).toBe(true);
23+
});
24+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Cross-cutting auth-layer behaviours that aren't tied to a specific route
2+
// family — see TRI-8743. Soft-deleted projects, revoked keys, expired JWTs,
3+
// cross-env mismatch, force-fallback toggle.
4+
5+
import { describe, expect, it } from "vitest";
6+
import { getTestServer } from "./helpers/sharedTestServer";
7+
8+
describe("Cross-cutting", () => {
9+
// Placeholder until TRI-8743 adds the actual matrix.
10+
it("shared prisma client can read from the postgres container", async () => {
11+
const server = getTestServer();
12+
const count = await server.prisma.user.count();
13+
expect(count).toBeGreaterThanOrEqual(0);
14+
});
15+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Comprehensive dashboard session-auth tests — see TRI-8742.
2+
// Each test seeds a User + session cookie via seedTestUser / seedTestSession
3+
// (helpers/seedTestSession.ts) and hits the shared webapp container.
4+
5+
import { describe, expect, it } from "vitest";
6+
import { getTestServer } from "./helpers/sharedTestServer";
7+
8+
describe("Dashboard", () => {
9+
// Placeholder until TRI-8742+ adds the actual matrix.
10+
it("shared webapp container redirects /admin/concurrency to /login when unauthenticated", async () => {
11+
const server = getTestServer();
12+
const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" });
13+
expect(res.status).toBe(302);
14+
});
15+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Produces a `Cookie:` header value for an authenticated session that the
2+
// webapp under test will accept. Mirrors the webapp's
3+
// `services/sessionStorage.server.ts` config exactly — the SESSION_SECRET
4+
// must match what the webapp container was started with (see
5+
// `internal-packages/testcontainers/src/webapp.ts` — currently
6+
// "test-session-secret-for-e2e-tests").
7+
//
8+
// Used by dashboard auth tests (TRI-8742). Each test seeds its own user +
9+
// session so test order doesn't matter.
10+
11+
import { createCookieSessionStorage } from "@remix-run/node";
12+
import type { PrismaClient } from "@trigger.dev/database";
13+
import { randomBytes } from "node:crypto";
14+
15+
// Must match SESSION_SECRET in internal-packages/testcontainers/src/webapp.ts.
16+
const SESSION_SECRET = "test-session-secret-for-e2e-tests";
17+
18+
// Shape of the session config in apps/webapp/app/services/sessionStorage.server.ts.
19+
const sessionStorage = createCookieSessionStorage({
20+
cookie: {
21+
name: "__session",
22+
sameSite: "lax",
23+
path: "/",
24+
httpOnly: true,
25+
secrets: [SESSION_SECRET],
26+
secure: false, // NODE_ENV is "test" in the spawned webapp.
27+
maxAge: 60 * 60 * 24 * 365,
28+
},
29+
});
30+
31+
export async function seedTestUser(
32+
prisma: PrismaClient,
33+
overrides?: { admin?: boolean; email?: string }
34+
) {
35+
const suffix = randomBytes(6).toString("hex");
36+
return prisma.user.create({
37+
data: {
38+
email: overrides?.email ?? `e2e-${suffix}@test.local`,
39+
authenticationMethod: "MAGIC_LINK",
40+
admin: overrides?.admin ?? false,
41+
},
42+
});
43+
}
44+
45+
// Builds the `Cookie:` header value for a given user. Set this on test
46+
// requests to the webapp to authenticate as that user.
47+
//
48+
// remix-auth's default sessionKey is "user" and stores AuthUser as
49+
// { userId } — see apps/webapp/app/services/authUser.ts.
50+
export async function seedTestSession(opts: { userId: string }): Promise<string> {
51+
const session = await sessionStorage.getSession();
52+
session.set("user", { userId: opts.userId });
53+
const setCookie = await sessionStorage.commitSession(session);
54+
// commitSession returns "__session=<value>; Path=/; ...". The Cookie
55+
// header only needs the name=value pair.
56+
const firstSegment = setCookie.split(";")[0];
57+
return firstSegment;
58+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Per-worker access to the shared TestServer started by globalSetup. Each
2+
// test file imports `getTestServer()` once at module top-level; the returned
3+
// value is a singleton within that worker process.
4+
//
5+
// `webapp.fetch(path)` prepends the shared baseUrl. The PrismaClient is
6+
// constructed lazily and disconnected on test-suite end via afterAll in the
7+
// importing file (or left to the worker shutting down).
8+
9+
import { PrismaClient } from "@trigger.dev/database";
10+
import { afterAll, inject } from "vitest";
11+
12+
interface SharedWebapp {
13+
baseUrl: string;
14+
fetch(path: string, init?: RequestInit): Promise<Response>;
15+
}
16+
17+
interface SharedTestServer {
18+
webapp: SharedWebapp;
19+
prisma: PrismaClient;
20+
}
21+
22+
let cached: SharedTestServer | undefined;
23+
24+
export function getTestServer(): SharedTestServer {
25+
if (cached) return cached;
26+
27+
const baseUrl = inject("baseUrl");
28+
const databaseUrl = inject("databaseUrl");
29+
30+
if (!baseUrl || !databaseUrl) {
31+
throw new Error(
32+
"globalSetup didn't provide baseUrl/databaseUrl — run via vitest.e2e.full.config.ts"
33+
);
34+
}
35+
36+
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
37+
38+
cached = {
39+
webapp: {
40+
baseUrl,
41+
fetch: (path, init) => fetch(`${baseUrl}${path}`, init),
42+
},
43+
prisma,
44+
};
45+
46+
// Disconnect the PrismaClient when the worker is done. globalSetup's
47+
// teardown stops the container; this just releases the per-worker pool.
48+
afterAll(async () => {
49+
await prisma.$disconnect().catch(() => {});
50+
});
51+
52+
return cached;
53+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// vitest globalSetup — runs once for the whole *.e2e.full.test.ts suite.
2+
// Boots one Postgres + Redis + webapp; tests connect to it via the
3+
// `baseUrl` / `databaseUrl` values provided to test workers below.
4+
//
5+
// Each test file recreates its own PrismaClient connected to the shared DB
6+
// (PrismaClient instances aren't serialisable across worker boundaries).
7+
8+
import type { TestProject } from "vitest/node";
9+
import { startTestServer, type TestServer } from "@internal/testcontainers/webapp";
10+
11+
let server: TestServer | undefined;
12+
13+
export default async function setup(project: TestProject) {
14+
server = await startTestServer();
15+
project.provide("baseUrl", server.webapp.baseUrl);
16+
project.provide("databaseUrl", server.databaseUrl);
17+
18+
return async () => {
19+
await server?.stop().catch(() => {});
20+
};
21+
}
22+
23+
declare module "vitest" {
24+
export interface ProvidedContext {
25+
baseUrl: string;
26+
databaseUrl: string;
27+
}
28+
}

0 commit comments

Comments
 (0)