Skip to content

Commit 8a3c5b6

Browse files
committed
RBAC: API auth e2e coverage — action + PAT + edge cases (TRI-8716)
Close the coverage gap identified in the TRI-8716 audit before TRI-8719 swaps apiBuilder.server.ts to rbac.authenticateBearer. All new tests run against the legacy authenticateApiRequestWithFailure / authenticateApiRequestWithPersonalAccessToken path and must stay green after the migration. - Action requests (createActionApiRoute) via POST /api/v1/idempotencyKeys/:key/reset: * Valid private API key → passes auth (400 on zod body validation, not 401/403). * Missing Authorization → 401. * Invalid API key → 401. - JWT on the same action route (allowJWT: true, superScopes write:runs, admin): * JWT with matching scope → passes auth. * JWT with read-only scope → 403. - Personal access tokens (createLoaderPATApiRoute) via GET /api/v1/projects/:ref/runs: * Missing Authorization → 401. * API key (tr_dev_*) on PAT-only route → 401. * Wrong-prefix or malformed PAT → 401. * Well-formed but unknown PAT → 401. * Revoked PAT → 401. * Valid PAT on unknown project → 404 (auth passes). - Edge case: valid API key whose project.deletedAt is set → 401. Also fix the TRI-8715 redirect assertion: the webapp sends clients to /login?redirectTo=... so compare by pathname rather than exact string. New helper test/helpers/seedTestPAT.ts seeds a User + PersonalAccessToken row using the same hashing/encryption scheme the webapp uses (shared test ENCRYPTION_KEY), so the webapp subprocess can authenticate against the seeded token. otu and realtime.skipColumns propagation are deferred: they're only observable via real trigger / realtime-stream flows, which need workers/streams enabled and are out of scope for a coverage PR. The migration preserves those fields via BearerAuthResult.jwt; dedicated coverage can ride with TRI-8719 if needed.
1 parent 29780a4 commit 8a3c5b6

2 files changed

Lines changed: 199 additions & 1 deletion

File tree

apps/webapp/test/api-auth.e2e.test.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { TestServer } from "@internal/testcontainers/webapp";
1111
import { startTestServer } from "@internal/testcontainers/webapp";
1212
import { generateJWT } from "@trigger.dev/core/v3/jwt";
1313
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
14+
import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT";
1415

1516
vi.setConfig({ testTimeout: 180_000 });
1617

@@ -129,6 +130,144 @@ describe("RBAC plugin — fallback wiring", () => {
129130
it("unauthenticated dashboard route redirects to /login via the OSS fallback", async () => {
130131
const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" });
131132
expect(res.status).toBe(302);
132-
expect(res.headers.get("location")).toBe("/login");
133+
const location = res.headers.get("location") ?? "";
134+
expect(new URL(location, "http://placeholder").pathname).toBe("/login");
135+
});
136+
});
137+
138+
// Covers createActionApiRoute's bearer auth path. The target route is
139+
// POST /api/v1/idempotencyKeys/:key/reset — allowJWT: true, superScopes: ["write:runs", "admin"].
140+
// Tests assert HTTP-observable behavior so they remain valid after TRI-8719 swaps
141+
// authenticateApiRequestWithFailure for rbac.authenticateBearer.
142+
describe("API bearer auth — action requests", () => {
143+
const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset";
144+
145+
it("valid API key: auth passes (body validation fails, not 401/403)", async () => {
146+
const { apiKey } = await seedTestEnvironment(server.prisma);
147+
const res = await server.webapp.fetch(targetPath, {
148+
method: "POST",
149+
headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
150+
body: JSON.stringify({}), // missing taskIdentifier → zod validation error
151+
});
152+
expect(res.status).not.toBe(401);
153+
expect(res.status).not.toBe(403);
154+
});
155+
156+
it("missing Authorization header: 401", async () => {
157+
const res = await server.webapp.fetch(targetPath, {
158+
method: "POST",
159+
headers: { "content-type": "application/json" },
160+
body: JSON.stringify({ taskIdentifier: "noop" }),
161+
});
162+
expect(res.status).toBe(401);
163+
});
164+
165+
it("invalid API key: 401", async () => {
166+
const res = await server.webapp.fetch(targetPath, {
167+
method: "POST",
168+
headers: {
169+
Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real",
170+
"content-type": "application/json",
171+
},
172+
body: JSON.stringify({ taskIdentifier: "noop" }),
173+
});
174+
expect(res.status).toBe(401);
175+
});
176+
177+
});
178+
179+
describe("JWT bearer auth — action requests", () => {
180+
const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset";
181+
182+
it("JWT with matching scope: auth passes", async () => {
183+
const { environment } = await seedTestEnvironment(server.prisma);
184+
const jwt = await generateTestJWT(environment, { scopes: ["write:runs"] });
185+
const res = await server.webapp.fetch(targetPath, {
186+
method: "POST",
187+
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
188+
body: JSON.stringify({}),
189+
});
190+
expect(res.status).not.toBe(401);
191+
expect(res.status).not.toBe(403);
192+
});
193+
194+
it("JWT with wrong scope (read-only) on write route: 403", async () => {
195+
const { environment } = await seedTestEnvironment(server.prisma);
196+
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
197+
const res = await server.webapp.fetch(targetPath, {
198+
method: "POST",
199+
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
200+
body: JSON.stringify({ taskIdentifier: "noop" }),
201+
});
202+
expect(res.status).toBe(403);
203+
});
204+
});
205+
206+
// Covers createLoaderPATApiRoute via GET /api/v1/projects/:projectRef/runs.
207+
// authenticateApiRequestWithPersonalAccessToken rejects anything that isn't tr_pat_-prefixed
208+
// or doesn't match a non-revoked PersonalAccessToken row.
209+
describe("Personal access token auth", () => {
210+
const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`;
211+
212+
it("missing Authorization header: 401", async () => {
213+
const res = await server.webapp.fetch(pathFor("nonexistent"));
214+
expect(res.status).toBe(401);
215+
});
216+
217+
it("API key (tr_dev_*) on PAT-only route: 401", async () => {
218+
const { apiKey } = await seedTestEnvironment(server.prisma);
219+
const res = await server.webapp.fetch(pathFor("nonexistent"), {
220+
headers: { Authorization: `Bearer ${apiKey}` },
221+
});
222+
expect(res.status).toBe(401);
223+
});
224+
225+
it("malformed PAT (wrong prefix): 401", async () => {
226+
const res = await server.webapp.fetch(pathFor("nonexistent"), {
227+
headers: { Authorization: "Bearer not_a_pat_at_all_random_string" },
228+
});
229+
expect(res.status).toBe(401);
230+
});
231+
232+
it("well-formed but unknown PAT: 401", async () => {
233+
const res = await server.webapp.fetch(pathFor("nonexistent"), {
234+
headers: {
235+
Authorization: "Bearer tr_pat_0000000000000000000000000000000000000000",
236+
},
237+
});
238+
expect(res.status).toBe(401);
239+
});
240+
241+
it("revoked PAT: 401", async () => {
242+
const user = await seedTestUser(server.prisma);
243+
const { token } = await seedTestPAT(server.prisma, user.id, { revoked: true });
244+
const res = await server.webapp.fetch(pathFor("nonexistent"), {
245+
headers: { Authorization: `Bearer ${token}` },
246+
});
247+
expect(res.status).toBe(401);
248+
});
249+
250+
it("valid PAT on nonexistent project: 404 (auth passes)", async () => {
251+
const user = await seedTestUser(server.prisma);
252+
const { token } = await seedTestPAT(server.prisma, user.id);
253+
const res = await server.webapp.fetch(pathFor("nonexistent"), {
254+
headers: { Authorization: `Bearer ${token}` },
255+
});
256+
expect(res.status).toBe(404);
257+
});
258+
});
259+
260+
// Edge cases where auth-path DB state should cause 401 even with a valid-looking token.
261+
describe("API bearer auth — environment/project edge cases", () => {
262+
it("valid API key whose project is soft-deleted: 401", async () => {
263+
const { apiKey, project } = await seedTestEnvironment(server.prisma);
264+
await server.prisma.project.update({
265+
where: { id: project.id },
266+
data: { deletedAt: new Date() },
267+
});
268+
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
269+
headers: { Authorization: `Bearer ${apiKey}` },
270+
});
271+
expect(res.status).toBe(401);
133272
});
134273
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { PrismaClient } from "@trigger.dev/database";
2+
import { createCipheriv, createHash, randomBytes } from "node:crypto";
3+
4+
// Must match ENCRYPTION_KEY in internal-packages/testcontainers/src/webapp.ts
5+
const ENCRYPTION_KEY = "test-encryption-key-for-e2e!!!!!";
6+
7+
function hashToken(token: string): string {
8+
return createHash("sha256").update(token).digest("hex");
9+
}
10+
11+
function encryptToken(value: string, key: string) {
12+
const nonce = randomBytes(12);
13+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
14+
let encrypted = cipher.update(value, "utf8", "hex");
15+
encrypted += cipher.final("hex");
16+
return {
17+
nonce: nonce.toString("hex"),
18+
ciphertext: encrypted,
19+
tag: cipher.getAuthTag().toString("hex"),
20+
};
21+
}
22+
23+
function obfuscate(token: string): string {
24+
return `${token.slice(0, 11)}${"•".repeat(20)}${token.slice(-4)}`;
25+
}
26+
27+
export async function seedTestUser(prisma: PrismaClient, overrides?: { admin?: boolean }) {
28+
const suffix = randomBytes(6).toString("hex");
29+
return prisma.user.create({
30+
data: {
31+
email: `pat-user-${suffix}@test.local`,
32+
authenticationMethod: "MAGIC_LINK",
33+
admin: overrides?.admin ?? false,
34+
},
35+
});
36+
}
37+
38+
// Seeds a PersonalAccessToken row using the same hashing/encryption scheme as
39+
// webapp's services/personalAccessToken.server.ts so the webapp subprocess can
40+
// authenticate against it.
41+
export async function seedTestPAT(
42+
prisma: PrismaClient,
43+
userId: string,
44+
opts: { revoked?: boolean } = {}
45+
): Promise<{ token: string; id: string }> {
46+
const token = `tr_pat_${randomBytes(20).toString("hex")}`;
47+
const encrypted = encryptToken(token, ENCRYPTION_KEY);
48+
const row = await prisma.personalAccessToken.create({
49+
data: {
50+
name: "e2e-test-pat",
51+
userId,
52+
encryptedToken: encrypted,
53+
hashedToken: hashToken(token),
54+
obfuscatedToken: obfuscate(token),
55+
revokedAt: opts.revoked ? new Date() : null,
56+
},
57+
});
58+
return { token, id: row.id };
59+
}

0 commit comments

Comments
 (0)