Skip to content

Commit 5872687

Browse files
committed
RBAC tests: waitpoint completions + input streams (TRI-8740)
Two routes share the same resource-id JWT pattern: - POST /api/v1/waitpoints/tokens/:friendlyId/complete - POST /realtime/v1/streams/:runId/input/:streamId The smoke matrix already exercises the full waitpoint scope matrix (exact-id, type-level, action mismatch, wrong type, admin super-scope). This adds: Waitpoints — gap-fill (3 cases): - private API key (tr_dev_*) → 200 - JWT with write:all → 200 - cross-env: env A's JWT cannot complete env B's waitpoint → not 200 Input streams — full matrix (9 cases, no smoke coverage): - missing auth → 401 - private API key → auth passes - JWT exact-id scope → auth passes - JWT type-level scope → auth passes - JWT wrong resource id → 403 - JWT read action on write route → 403 - JWT write:all → auth passes - JWT admin → auth passes - cross-env JWT → not 200 (security property) Pass-cases on input streams use "not 401, not 403" rather than asserting a specific 2xx — the realtime streams path returns various codes depending on stream state, but the auth layer is the only thing this test cares about. Reuses seedTestWaitpoint and seedTestRun from the existing helpers. No new fixtures. Verification: typecheck clean. Test execution deferred to your normal e2e run.
1 parent 6744bf2 commit 5872687

1 file changed

Lines changed: 243 additions & 0 deletions

File tree

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

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import { describe, expect, it } from "vitest";
1515
import { getTestServer } from "./helpers/sharedTestServer";
1616
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
1717
import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT";
18+
import { seedTestRun } from "./helpers/seedTestRun";
1819
import { seedTestUserProject } from "./helpers/seedTestUserProject";
20+
import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint";
1921

2022
describe("API", () => {
2123
// Placeholder until family subtasks add their describes (TRI-8733+).
@@ -133,4 +135,245 @@ describe("API", () => {
133135
expect(res.status).toBe(200);
134136
});
135137
});
138+
139+
// Resource-scoped writes (TRI-8740). Two routes:
140+
// - POST /api/v1/waitpoints/tokens/:friendlyId/complete
141+
// resource: { type: "waitpoints", id: friendlyId }
142+
// - POST /realtime/v1/streams/:runId/input/:streamId
143+
// resource: { type: "inputStreams", id: runId }
144+
//
145+
// The smoke matrix (api-auth.e2e.test.ts "JWT bearer auth — resource-
146+
// scoped scopes") already covers waitpoints comprehensively for JWT
147+
// resource-id matching, type-level scopes, action mismatches, admin
148+
// super-scope, etc. This block fills the gaps:
149+
// - Private API key (not JWT) on the route.
150+
// - JWT with `write:all` super-scope.
151+
// - Cross-env (env A's JWT trying env B's resource).
152+
// Plus the equivalent full matrix for input-streams which the smoke
153+
// matrix doesn't touch.
154+
describe("Resource-scoped writes — waitpoints (gap-fill)", () => {
155+
const pathFor = (friendlyId: string) =>
156+
`/api/v1/waitpoints/tokens/${friendlyId}/complete`;
157+
const completeRequest = (path: string, headers: Record<string, string>) =>
158+
getTestServer().webapp.fetch(path, {
159+
method: "POST",
160+
headers: { "Content-Type": "application/json", ...headers },
161+
body: JSON.stringify({}),
162+
});
163+
164+
async function seedEnvAndWaitpoint() {
165+
const server = getTestServer();
166+
const seed = await seedTestEnvironment(server.prisma);
167+
const waitpoint = await seedTestWaitpoint(server.prisma, {
168+
environmentId: seed.environment.id,
169+
projectId: seed.project.id,
170+
});
171+
return { ...seed, waitpoint };
172+
}
173+
174+
it("private API key (tr_dev_*): auth passes (200)", async () => {
175+
const { apiKey, waitpoint } = await seedEnvAndWaitpoint();
176+
const res = await completeRequest(pathFor(waitpoint.friendlyId), {
177+
Authorization: `Bearer ${apiKey}`,
178+
});
179+
// Waitpoint is COMPLETED, so the handler short-circuits with 200
180+
// once auth passes. Auth-passed assertion: NOT 401 / 403.
181+
expect(res.status).toBe(200);
182+
});
183+
184+
it("JWT with write:all super-scope: auth passes (200)", async () => {
185+
const { environment, waitpoint } = await seedEnvAndWaitpoint();
186+
const jwt = await generateJWT({
187+
secretKey: environment.apiKey,
188+
payload: { pub: true, sub: environment.id, scopes: ["write:all"] },
189+
expirationTime: "15m",
190+
});
191+
const res = await completeRequest(pathFor(waitpoint.friendlyId), {
192+
Authorization: `Bearer ${jwt}`,
193+
});
194+
expect(res.status).toBe(200);
195+
});
196+
197+
it("cross-env: env A's JWT cannot complete env B's waitpoint: not 200", async () => {
198+
const server = getTestServer();
199+
const a = await seedTestEnvironment(server.prisma);
200+
const b = await seedEnvAndWaitpoint();
201+
const jwt = await generateJWT({
202+
secretKey: a.apiKey,
203+
payload: {
204+
pub: true,
205+
sub: a.environment.id,
206+
scopes: [`write:waitpoints:${b.waitpoint.friendlyId}`],
207+
},
208+
expirationTime: "15m",
209+
});
210+
// The JWT is signed by env A and its sub claim says env A. The
211+
// route resolves env from the sub claim and the waitpoint is
212+
// env B's, so the lookup misses. The exact code depends on
213+
// whether auth or the resource lookup fires first — both
214+
// outcomes are correct, just NOT 200.
215+
const res = await completeRequest(pathFor(b.waitpoint.friendlyId), {
216+
Authorization: `Bearer ${jwt}`,
217+
});
218+
expect(res.status).not.toBe(200);
219+
});
220+
});
221+
222+
describe("Resource-scoped writes — input streams (full matrix)", () => {
223+
const pathFor = (runId: string, streamId: string) =>
224+
`/realtime/v1/streams/${runId}/input/${streamId}`;
225+
const postRequest = (path: string, headers: Record<string, string>) =>
226+
getTestServer().webapp.fetch(path, {
227+
method: "POST",
228+
headers: { "Content-Type": "application/json", ...headers },
229+
body: JSON.stringify({ data: { hello: "world" } }),
230+
});
231+
232+
async function seedEnvAndRun() {
233+
const server = getTestServer();
234+
const seed = await seedTestEnvironment(server.prisma);
235+
const { runFriendlyId } = await seedTestRun(server.prisma, {
236+
environmentId: seed.environment.id,
237+
projectId: seed.project.id,
238+
});
239+
return { ...seed, runFriendlyId, streamId: "test-stream" };
240+
}
241+
242+
it("missing auth: 401", async () => {
243+
const server = getTestServer();
244+
const res = await server.webapp.fetch(pathFor("run_doesnotexist", "stream-x"), {
245+
method: "POST",
246+
headers: { "Content-Type": "application/json" },
247+
body: JSON.stringify({ data: {} }),
248+
});
249+
expect(res.status).toBe(401);
250+
});
251+
252+
it("private API key: auth passes (not 401/403)", async () => {
253+
const { apiKey, runFriendlyId, streamId } = await seedEnvAndRun();
254+
const res = await postRequest(pathFor(runFriendlyId, streamId), {
255+
Authorization: `Bearer ${apiKey}`,
256+
});
257+
// Route may return any 2xx/4xx based on stream state — we only
258+
// care that auth passed (NOT 401/403).
259+
expect(res.status).not.toBe(401);
260+
expect(res.status).not.toBe(403);
261+
});
262+
263+
it("JWT with exact-id scope: auth passes", async () => {
264+
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
265+
const jwt = await generateJWT({
266+
secretKey: environment.apiKey,
267+
payload: {
268+
pub: true,
269+
sub: environment.id,
270+
scopes: [`write:inputStreams:${runFriendlyId}`],
271+
},
272+
expirationTime: "15m",
273+
});
274+
const res = await postRequest(pathFor(runFriendlyId, streamId), {
275+
Authorization: `Bearer ${jwt}`,
276+
});
277+
expect(res.status).not.toBe(401);
278+
expect(res.status).not.toBe(403);
279+
});
280+
281+
it("JWT with type-level scope: auth passes", async () => {
282+
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
283+
const jwt = await generateJWT({
284+
secretKey: environment.apiKey,
285+
payload: { pub: true, sub: environment.id, scopes: ["write:inputStreams"] },
286+
expirationTime: "15m",
287+
});
288+
const res = await postRequest(pathFor(runFriendlyId, streamId), {
289+
Authorization: `Bearer ${jwt}`,
290+
});
291+
expect(res.status).not.toBe(401);
292+
expect(res.status).not.toBe(403);
293+
});
294+
295+
it("JWT with wrong resource id: 403", async () => {
296+
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
297+
const jwt = await generateJWT({
298+
secretKey: environment.apiKey,
299+
payload: {
300+
pub: true,
301+
sub: environment.id,
302+
scopes: ["write:inputStreams:run_someoneelse00000000000000"],
303+
},
304+
expirationTime: "15m",
305+
});
306+
const res = await postRequest(pathFor(runFriendlyId, streamId), {
307+
Authorization: `Bearer ${jwt}`,
308+
});
309+
expect(res.status).toBe(403);
310+
});
311+
312+
it("JWT with read action on write route: 403", async () => {
313+
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
314+
const jwt = await generateJWT({
315+
secretKey: environment.apiKey,
316+
payload: {
317+
pub: true,
318+
sub: environment.id,
319+
scopes: [`read:inputStreams:${runFriendlyId}`],
320+
},
321+
expirationTime: "15m",
322+
});
323+
const res = await postRequest(pathFor(runFriendlyId, streamId), {
324+
Authorization: `Bearer ${jwt}`,
325+
});
326+
expect(res.status).toBe(403);
327+
});
328+
329+
it("JWT with write:all super-scope: auth passes", async () => {
330+
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
331+
const jwt = await generateJWT({
332+
secretKey: environment.apiKey,
333+
payload: { pub: true, sub: environment.id, scopes: ["write:all"] },
334+
expirationTime: "15m",
335+
});
336+
const res = await postRequest(pathFor(runFriendlyId, streamId), {
337+
Authorization: `Bearer ${jwt}`,
338+
});
339+
expect(res.status).not.toBe(401);
340+
expect(res.status).not.toBe(403);
341+
});
342+
343+
it("JWT with admin super-scope: auth passes", async () => {
344+
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
345+
const jwt = await generateJWT({
346+
secretKey: environment.apiKey,
347+
payload: { pub: true, sub: environment.id, scopes: ["admin"] },
348+
expirationTime: "15m",
349+
});
350+
const res = await postRequest(pathFor(runFriendlyId, streamId), {
351+
Authorization: `Bearer ${jwt}`,
352+
});
353+
expect(res.status).not.toBe(401);
354+
expect(res.status).not.toBe(403);
355+
});
356+
357+
it("cross-env: env A's JWT cannot write to env B's run: not 200", async () => {
358+
const server = getTestServer();
359+
const a = await seedTestEnvironment(server.prisma);
360+
const b = await seedEnvAndRun();
361+
const jwt = await generateJWT({
362+
secretKey: a.apiKey,
363+
payload: {
364+
pub: true,
365+
sub: a.environment.id,
366+
scopes: [`write:inputStreams:${b.runFriendlyId}`],
367+
},
368+
expirationTime: "15m",
369+
});
370+
const res = await postRequest(pathFor(b.runFriendlyId, b.streamId), {
371+
Authorization: `Bearer ${jwt}`,
372+
});
373+
// Either auth fails outright or the run lookup misses (env A's
374+
// view of the run doesn't include env B's data). Critical
375+
// security property: NOT 200.
376+
expect(res.status).not.toBe(200);
377+
});
378+
});
136379
});

0 commit comments

Comments
 (0)