Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.4k
test: e2e auth baseline tests + webapp testcontainer infrastructure#3438
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
3fe654a7292806b3c3f15ef83853121cda18413af5419a2df9e9ff32407a3c090f7f13b58176f63030bfacdbbb6a846e7224399e319bc016dee80aeFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
| 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, 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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)}`; | ||
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Uh oh!There was an error while loading. Please reload this page. | ||
| const pkApiKey = `pk_dev_${randomHex(24)}`; | ||
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Uh oh!There was an error while loading. Please reload this page. coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -4,6 +4,7 @@ import tsconfigPaths from "vite-tsconfig-paths"; | ||
| export default defineConfig({ | ||
| test: { | ||
| include: ["test/**/*.test.ts"], | ||
| exclude: ["test/**/*.e2e.test.ts"], | ||
devin-ai-integration[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| globals: true, | ||
| pool: "forks", | ||
| }, | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] })], | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.