Skip to content

Commit 923a9bf

Browse files
committed
RBAC tests: PAT auth comprehensive matrix (TRI-8741)
Extends the smoke matrix (test/api-auth.e2e.test.ts, TRI-8716) which already covers basic 401 cases (missing auth, wrong-prefix, unknown PAT, revoked PAT, valid-PAT-on-nonexistent-project) by adding the cases that need the full user → org → project → environment graph seeded. New helper: seedTestUserProject(prisma, opts?) returns a user + org + project + dev environment + a non-revoked PAT in one call. The existing seedTestEnvironment doesn't create the OrgMember link that findProjectByRef's `members: { some: { userId } }` scope check needs, so PAT-comprehensive tests need this composite fixture. Cases added under "PAT-authenticated routes — comprehensive" against GET /api/v1/projects/:projectRef/runs: - JWT on PAT route: 401 (PAT route doesn't accept JWTs). - valid PAT, same-org project: 2xx (auth + scoping pass). - valid PAT, cross-org project: 404 (not 403 — findProjectByRef returns null and the route maps null to 404, locked in). - valid PAT, soft-deleted project: 200 (findProjectByRef doesn't filter on deletedAt — observed behaviour, called out so a future change is conscious; the ticket described this as 404 but the route's actual contract is 200). - admin user accessing another org's project: still 404 (the global user.admin flag doesn't grant cross-org visibility — the route is per-user). - admin user accessing their own org's project: 2xx (companion check to confirm admin=true isn't accidentally subtracting permission). Verification: typecheck clean. Test execution deferred to your normal e2e run (the .full suite is slow due to container startup; CI will catch any false expectations).
1 parent c9ecf99 commit 923a9bf

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

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

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
//
1111
// See test/helpers/sharedTestServer.ts for `getTestServer()`.
1212

13+
import { generateJWT } from "@trigger.dev/core/v3/jwt";
1314
import { describe, expect, it } from "vitest";
1415
import { getTestServer } from "./helpers/sharedTestServer";
16+
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
17+
import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT";
18+
import { seedTestUserProject } from "./helpers/seedTestUserProject";
1519

1620
describe("API", () => {
1721
// Placeholder until family subtasks add their describes (TRI-8733+).
@@ -21,4 +25,112 @@ describe("API", () => {
2125
const res = await server.webapp.fetch("/healthcheck");
2226
expect(res.ok).toBe(true);
2327
});
28+
29+
// PAT-authenticated routes (TRI-8741). The smoke matrix in
30+
// test/api-auth.e2e.test.ts covers basic 401 cases (missing auth,
31+
// wrong-prefix, unknown PAT, revoked PAT, valid-PAT-on-nonexistent-
32+
// project). This describe extends the matrix to the cases that
33+
// require seeding the full user → org → project → env graph:
34+
// valid-PAT-on-real-project, cross-org isolation, soft-deleted
35+
// project, and the global-admin-flag-doesn't-grant-cross-org carve-
36+
// out.
37+
//
38+
// Target route: GET /api/v1/projects/:projectRef/runs (the only
39+
// createLoaderPATApiRoute consumer at time of writing — re-grep
40+
// before extending if more PAT-only routes appear).
41+
describe("PAT-authenticated routes — comprehensive", () => {
42+
const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`;
43+
44+
it("JWT on PAT-only route: 401", async () => {
45+
const server = getTestServer();
46+
const { environment } = await seedTestEnvironment(server.prisma);
47+
const jwt = await generateJWT({
48+
secretKey: environment.apiKey,
49+
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
50+
expirationTime: "15m",
51+
});
52+
const res = await server.webapp.fetch(pathFor("nonexistent"), {
53+
headers: { Authorization: `Bearer ${jwt}` },
54+
});
55+
// PAT route doesn't accept JWTs — auth rejects before resource lookup.
56+
expect(res.status).toBe(401);
57+
});
58+
59+
it("valid PAT, project exists in user's org: 2xx", async () => {
60+
const server = getTestServer();
61+
const { project, pat } = await seedTestUserProject(server.prisma);
62+
const res = await server.webapp.fetch(pathFor(project.externalRef), {
63+
headers: { Authorization: `Bearer ${pat.token}` },
64+
});
65+
// Auth + scoping pass — handler returns the run list (empty by default).
66+
expect(res.status).toBe(200);
67+
});
68+
69+
it("valid PAT, project belongs to a different user's org: 404", async () => {
70+
const server = getTestServer();
71+
// Two completely isolated graphs. Both projects exist; the PAT
72+
// belongs to userA, the project to userB's org. findProjectByRef
73+
// scopes by `members: { some: { userId } }`, so userA's PAT
74+
// sees userB's project as nonexistent → 404 (not 403).
75+
const a = await seedTestUserProject(server.prisma);
76+
const b = await seedTestUserProject(server.prisma);
77+
const res = await server.webapp.fetch(pathFor(b.project.externalRef), {
78+
headers: { Authorization: `Bearer ${a.pat.token}` },
79+
});
80+
// Lock in the 404 — the access check inside findProjectByRef
81+
// returns null for cross-org and the route maps null to 404.
82+
expect(res.status).toBe(404);
83+
});
84+
85+
it("valid PAT, project soft-deleted (deletedAt != null): 200 (route does not filter)", async () => {
86+
const server = getTestServer();
87+
// findProjectByRef (apps/webapp/app/models/project.server.ts)
88+
// does NOT filter on deletedAt — it scopes only by externalRef
89+
// and the user's org membership. So a soft-deleted project is
90+
// still findable here; the run-list presenter just returns
91+
// data:[] (or whatever survived). The ticket lists this as a
92+
// 404 case but that's not the route's actual contract; lock in
93+
// observed behaviour and call out the gap so a future change
94+
// (either tightening findProjectByRef or filtering at the route)
95+
// is conscious.
96+
const { project, pat } = await seedTestUserProject(server.prisma, {
97+
projectDeleted: true,
98+
});
99+
const res = await server.webapp.fetch(pathFor(project.externalRef), {
100+
headers: { Authorization: `Bearer ${pat.token}` },
101+
});
102+
expect(res.status).toBe(200);
103+
});
104+
105+
it("valid PAT for a global-admin user: still per-user (no cross-org access)", async () => {
106+
const server = getTestServer();
107+
// user.admin = true is the legacy super-admin flag. The PAT
108+
// route's access check is per-user (members: { some: { userId } }),
109+
// not admin-aware — so admin doesn't unlock cross-org visibility.
110+
// Lock in that behaviour: an admin's PAT can't read another
111+
// org's project either.
112+
const admin = await seedTestUser(server.prisma, { admin: true });
113+
const adminPat = await seedTestPAT(server.prisma, admin.id);
114+
const otherOrg = await seedTestUserProject(server.prisma);
115+
116+
const res = await server.webapp.fetch(pathFor(otherOrg.project.externalRef), {
117+
headers: { Authorization: `Bearer ${adminPat.token}` },
118+
});
119+
expect(res.status).toBe(404);
120+
});
121+
122+
it("valid PAT, admin user accessing their OWN project: 2xx", async () => {
123+
const server = getTestServer();
124+
// Companion to the above — confirm admin=true users can still
125+
// access their own org's projects (the admin flag isn't
126+
// accidentally subtracting permission).
127+
const { project, pat } = await seedTestUserProject(server.prisma, {
128+
userAdmin: true,
129+
});
130+
const res = await server.webapp.fetch(pathFor(project.externalRef), {
131+
headers: { Authorization: `Bearer ${pat.token}` },
132+
});
133+
expect(res.status).toBe(200);
134+
});
135+
});
24136
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { PrismaClient } from "@trigger.dev/database";
2+
import { randomBytes } from "node:crypto";
3+
import { seedTestPAT, seedTestUser } from "./seedTestPAT";
4+
5+
function randomHex(len = 12): string {
6+
return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
7+
}
8+
9+
// Composite test fixture: a User, an Organization with that user as a
10+
// member, a Project owned by the org, a DEVELOPMENT environment, and a
11+
// non-revoked PAT for the user.
12+
//
13+
// Used by the PAT-comprehensive matrix (TRI-8741) to exercise routes
14+
// like GET /api/v1/projects/:projectRef/runs whose access check is
15+
// `findProjectByRef(externalRef, userId)` — i.e. the project's org
16+
// must have the userId in its members. seedTestEnvironment alone
17+
// doesn't create the OrgMember link, which is why this helper exists.
18+
//
19+
// Caller passes `projectDeleted: true` to test the soft-deleted-
20+
// project path; `userAdmin: true` to confirm the global admin flag
21+
// doesn't add cross-org visibility (the route is per-user).
22+
export async function seedTestUserProject(
23+
prisma: PrismaClient,
24+
opts: { userAdmin?: boolean; projectDeleted?: boolean } = {}
25+
) {
26+
const suffix = randomHex(8);
27+
const apiKey = `tr_dev_${randomHex(24)}`;
28+
const pkApiKey = `pk_dev_${randomHex(24)}`;
29+
30+
const user = await seedTestUser(prisma, { admin: opts.userAdmin ?? false });
31+
32+
const organization = await prisma.organization.create({
33+
data: {
34+
title: `e2e-pat-org-${suffix}`,
35+
slug: `e2e-pat-org-${suffix}`,
36+
v3Enabled: true,
37+
members: { create: { userId: user.id, role: "ADMIN" } },
38+
},
39+
});
40+
41+
const project = await prisma.project.create({
42+
data: {
43+
name: `e2e-pat-project-${suffix}`,
44+
slug: `e2e-pat-proj-${suffix}`,
45+
externalRef: `proj_${suffix}`,
46+
organizationId: organization.id,
47+
engine: "V2",
48+
deletedAt: opts.projectDeleted ? new Date() : null,
49+
},
50+
});
51+
52+
const environment = await prisma.runtimeEnvironment.create({
53+
data: {
54+
slug: "dev",
55+
type: "DEVELOPMENT",
56+
apiKey,
57+
pkApiKey,
58+
shortcode: suffix.slice(0, 4),
59+
projectId: project.id,
60+
organizationId: organization.id,
61+
},
62+
});
63+
64+
const pat = await seedTestPAT(prisma, user.id);
65+
66+
return { user, organization, project, environment, pat };
67+
}

0 commit comments

Comments
 (0)