/** * 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"; import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT"; import { seedTestRun } from "./helpers/seedTestRun"; import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint"; 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 { 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); }); }); // Exercises the RBAC plugin loader end-to-end. The test server boots // with RBAC_FORCE_FALLBACK=1 (see internal-packages/testcontainers/src/webapp.ts), // which makes rbac.server.ts use the default fallback regardless of // whether a plugin is installed in node_modules. /admin/concurrency // uses rbac.authenticateSession internally; an unauthenticated request // must flow through LazyController → RoleBaseAccessFallback → // redirect("/login"). describe("RBAC plugin — fallback wiring", () => { it("unauthenticated dashboard route redirects to /login via the fallback", async () => { const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" }); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(new URL(location, "http://placeholder").pathname).toBe("/login"); }); }); // Covers createActionApiRoute's bearer auth path. The target route is // POST /api/v1/idempotencyKeys/:key/reset — allowJWT: true, superScopes: ["write:runs", "admin"]. // Tests assert HTTP-observable behavior so they remain valid after TRI-8719 swaps // authenticateApiRequestWithFailure for rbac.authenticateBearer. describe("API bearer auth — action requests", () => { const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset"; it("valid API key: auth passes (body validation fails, not 401/403)", async () => { const { apiKey } = await seedTestEnvironment(server.prisma); const res = await server.webapp.fetch(targetPath, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, body: JSON.stringify({}), // missing taskIdentifier → zod validation error }); expect(res.status).not.toBe(401); expect(res.status).not.toBe(403); }); it("missing Authorization header: 401", async () => { const res = await server.webapp.fetch(targetPath, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ taskIdentifier: "noop" }), }); expect(res.status).toBe(401); }); it("invalid API key: 401", async () => { const res = await server.webapp.fetch(targetPath, { method: "POST", headers: { Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real", "content-type": "application/json", }, body: JSON.stringify({ taskIdentifier: "noop" }), }); expect(res.status).toBe(401); }); }); describe("JWT bearer auth — action requests", () => { const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset"; it("JWT with matching scope: auth passes", async () => { const { environment } = await seedTestEnvironment(server.prisma); const jwt = await generateTestJWT(environment, { scopes: ["write:runs"] }); const res = await server.webapp.fetch(targetPath, { method: "POST", headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).not.toBe(401); expect(res.status).not.toBe(403); }); it("JWT with wrong scope (read-only) on write route: 403", async () => { const { environment } = await seedTestEnvironment(server.prisma); const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] }); const res = await server.webapp.fetch(targetPath, { method: "POST", headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, body: JSON.stringify({ taskIdentifier: "noop" }), }); expect(res.status).toBe(403); }); }); // Covers createLoaderPATApiRoute via GET /api/v1/projects/:projectRef/runs. // authenticateApiRequestWithPersonalAccessToken rejects anything that isn't tr_pat_-prefixed // or doesn't match a non-revoked PersonalAccessToken row. describe("Personal access token auth", () => { const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`; it("missing Authorization header: 401", async () => { const res = await server.webapp.fetch(pathFor("nonexistent")); expect(res.status).toBe(401); }); it("API key (tr_dev_*) on PAT-only route: 401", async () => { const { apiKey } = await seedTestEnvironment(server.prisma); const res = await server.webapp.fetch(pathFor("nonexistent"), { headers: { Authorization: `Bearer ${apiKey}` }, }); expect(res.status).toBe(401); }); it("malformed PAT (wrong prefix): 401", async () => { const res = await server.webapp.fetch(pathFor("nonexistent"), { headers: { Authorization: "Bearer not_a_pat_at_all_random_string" }, }); expect(res.status).toBe(401); }); it("well-formed but unknown PAT: 401", async () => { const res = await server.webapp.fetch(pathFor("nonexistent"), { headers: { Authorization: "Bearer tr_pat_0000000000000000000000000000000000000000", }, }); expect(res.status).toBe(401); }); it("revoked PAT: 401", async () => { const user = await seedTestUser(server.prisma); const { token } = await seedTestPAT(server.prisma, user.id, { revoked: true }); const res = await server.webapp.fetch(pathFor("nonexistent"), { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(401); }); it("valid PAT on nonexistent project: 404 (auth passes)", async () => { const user = await seedTestUser(server.prisma); const { token } = await seedTestPAT(server.prisma, user.id); const res = await server.webapp.fetch(pathFor("nonexistent"), { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(404); }); }); // Verifies resource-scoped JWT behaviour end-to-end against a real seeded resource. // Target: POST /api/v1/waitpoints/tokens/:waitpointFriendlyId/complete — allowJWT: true, // authorization: { action: "write", resource: (params) => ({ waitpoints: params.waitpointFriendlyId }), // superScopes: ["write:waitpoints", "admin"] }. // // The Waitpoint is seeded with status COMPLETED so the handler short-circuits with // { success: true } once auth passes — no run-engine worker needed. "Auth passes" is // observable as a 200 response; "auth fails" is observable as a 403. describe("JWT bearer auth — resource-scoped scopes", () => { const pathFor = (friendlyId: string) => `/api/v1/waitpoints/tokens/${friendlyId}/complete`; async function seedEnvAndWaitpoint() { const seed = await seedTestEnvironment(server.prisma); const waitpoint = await seedTestWaitpoint(server.prisma, { environmentId: seed.environment.id, projectId: seed.project.id, }); return { ...seed, waitpoint }; } async function completeRequest(friendlyId: string, jwt: string) { return server.webapp.fetch(pathFor(friendlyId), { method: "POST", headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, body: JSON.stringify({}), }); } it("scope matches exact resource id: 200", async () => { const { environment, waitpoint } = await seedEnvAndWaitpoint(); const jwt = await generateTestJWT(environment, { scopes: [`write:waitpoints:${waitpoint.friendlyId}`], }); const res = await completeRequest(waitpoint.friendlyId, jwt); expect(res.status).toBe(200); }); it("scope targets a different resource id: 403", async () => { const { environment, waitpoint } = await seedEnvAndWaitpoint(); const jwt = await generateTestJWT(environment, { scopes: ["write:waitpoints:waitpoint_someoneelse000000000000000"], }); const res = await completeRequest(waitpoint.friendlyId, jwt); expect(res.status).toBe(403); }); it("type-level scope (no id) grants all resources of that type: 200", async () => { const { environment, waitpoint } = await seedEnvAndWaitpoint(); const jwt = await generateTestJWT(environment, { scopes: ["write:waitpoints"] }); const res = await completeRequest(waitpoint.friendlyId, jwt); expect(res.status).toBe(200); }); it("scope action mismatch (read-only on write route) with matching resource id: 403", async () => { const { environment, waitpoint } = await seedEnvAndWaitpoint(); const jwt = await generateTestJWT(environment, { scopes: [`read:waitpoints:${waitpoint.friendlyId}`], }); const res = await completeRequest(waitpoint.friendlyId, jwt); expect(res.status).toBe(403); }); it("scope targets a different resource type: 403", async () => { const { environment, waitpoint } = await seedEnvAndWaitpoint(); const jwt = await generateTestJWT(environment, { scopes: ["write:runs:run_abc000000000000000000000"], }); const res = await completeRequest(waitpoint.friendlyId, jwt); expect(res.status).toBe(403); }); it("admin super-scope grants access (legacy behaviour): 200", async () => { const { environment, waitpoint } = await seedEnvAndWaitpoint(); const jwt = await generateTestJWT(environment, { scopes: ["admin"] }); const res = await completeRequest(waitpoint.friendlyId, jwt); expect(res.status).toBe(200); }); it("unrelated type scope with no super-scope match: 403", async () => { const { environment, waitpoint } = await seedEnvAndWaitpoint(); const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] }); const res = await completeRequest(waitpoint.friendlyId, jwt); expect(res.status).toBe(403); }); }); // Pre-migration coverage for the three behavioural constraints captured in TRI-8719. // Each test locks in an observable current behaviour that the migration must preserve: // - custom actions (trigger/batchTrigger/update) satisfied by write:* scopes // - multi-key resource callbacks (runs/tags/batch/tasks) — any key match grants access // - empty resource callbacks relying on superScopes describe("JWT bearer auth — behaviours to preserve through TRI-8719", () => { it('custom action: type-level write:tasks scope satisfies action="trigger" (auth passes)', async () => { const { environment } = await seedTestEnvironment(server.prisma); // Current SDK + MCP JWTs for task-trigger use type-level scope, e.g. write:tasks. // Legacy checkAuthorization passes via exact superScope match ["write:tasks", "admin"]. // After TRI-8719, the ACTION_ALIASES map must keep this working: trigger action is // satisfied by a scope whose action is write. const jwt = await generateTestJWT(environment, { scopes: ["write:tasks"] }); const res = await server.webapp.fetch("/api/v1/tasks/nonexistent-task/trigger", { method: "POST", headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).not.toBe(401); expect(res.status).not.toBe(403); }); it("multi-key resource: read:tags: scope grants access to a run carrying that tag (auth passes)", async () => { const { environment, project } = await seedTestEnvironment(server.prisma); const { runFriendlyId } = await seedTestRun(server.prisma, { environmentId: environment.id, projectId: project.id, runTags: ["my-resource-scoped-tag"], }); const jwt = await generateTestJWT(environment, { scopes: ["read:tags:my-resource-scoped-tag"], }); const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, { headers: { Authorization: `Bearer ${jwt}` }, }); expect(res.status).not.toBe(401); expect(res.status).not.toBe(403); }); it("multi-key resource: read:batch: scope grants access to a run in that batch (auth passes)", async () => { const { environment, project } = await seedTestEnvironment(server.prisma); const { runFriendlyId, batchFriendlyId } = await seedTestRun(server.prisma, { environmentId: environment.id, projectId: project.id, withBatch: true, }); const jwt = await generateTestJWT(environment, { scopes: [`read:batch:${batchFriendlyId}`], }); const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, { headers: { Authorization: `Bearer ${jwt}` }, }); expect(res.status).not.toBe(401); expect(res.status).not.toBe(403); }); // Empty-resource routes (api.v1.batches.ts, api.v1.idempotencyKeys.$key.reset.ts) // currently DENY all JWTs because legacy checkAuthorization's empty-resource check // fires before the superScope check. TRI-8719's plan to add explicit { type: "runs" } // changes this to "JWTs with read:runs or write:runs now work on these routes" — an // intentional improvement, not a preserved behaviour. See TRI-8719 description for // the note; there's nothing to lock in with a test here. }); // Edge cases where auth-path DB state should cause 401 even with a valid-looking token. describe("API bearer auth — environment/project edge cases", () => { it("valid API key whose project is soft-deleted: 401", async () => { const { apiKey, project } = await seedTestEnvironment(server.prisma); await server.prisma.project.update({ where: { id: project.id }, data: { deletedAt: new Date() }, }); const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", { headers: { Authorization: `Bearer ${apiKey}` }, }); expect(res.status).toBe(401); }); });