Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/e2e-webapp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 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
3 changes: 3 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +13 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 E2E tests run in the unit-tests workflow rather than a dedicated pipeline

The new e2e-webapp.yml workflow is called from .github/workflows/unit-tests.yml alongside the actual unit test jobs. E2E tests (starting containers, building the webapp, running a server) are significantly heavier and slower than unit tests. This coupling means E2E failures block the unit tests workflow. This is a design choice that may be intentional for simplicity, but a dedicated workflow or a separate CI job group might be more appropriate as E2E tests grow.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

packages:
uses: ./.github/workflows/unit-tests-packages.yml
secrets: inherit
Expand Down
121 changes: 121 additions & 0 deletions apps/webapp/test/api-auth.e2e.test.ts
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);
});
});
44 changes: 44 additions & 0 deletions apps/webapp/test/helpers/seedTestEnvironment.ts
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)}`;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const pkApiKey = `pk_dev_${randomHex(24)}`;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 change: 1 addition & 0 deletions apps/webapp/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
exclude: ["test/**/*.e2e.test.ts"],
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
globals: true,
pool: "forks",
},
Expand Down
12 changes: 12 additions & 0 deletions apps/webapp/vitest.e2e.config.ts
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"] })],
});
4 changes: 4 additions & 0 deletions internal-packages/testcontainers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal-packages/testcontainers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
Loading
Loading