@@ -15,7 +15,9 @@ import { describe, expect, it } from "vitest";
1515import { getTestServer } from "./helpers/sharedTestServer" ;
1616import { seedTestEnvironment } from "./helpers/seedTestEnvironment" ;
1717import { seedTestPAT , seedTestUser } from "./helpers/seedTestPAT" ;
18+ import { seedTestRun } from "./helpers/seedTestRun" ;
1819import { seedTestUserProject } from "./helpers/seedTestUserProject" ;
20+ import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint" ;
1921
2022describe ( "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