@@ -10,6 +10,8 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
1010import type { TestServer } from "@internal/testcontainers/webapp" ;
1111import { startTestServer } from "@internal/testcontainers/webapp" ;
1212import { generateJWT } from "@trigger.dev/core/v3/jwt" ;
13+ import { createHash } from "node:crypto" ;
14+ import { generateAdditionalApiKey } from "~/utils/apiKeys" ;
1315import { seedTestEnvironment } from "./helpers/seedTestEnvironment" ;
1416import { seedTestPAT , seedTestUser } from "./helpers/seedTestPAT" ;
1517import { seedTestRun } from "./helpers/seedTestRun" ;
@@ -146,41 +148,159 @@ describe("Packet download authorization", () => {
146148 expect ( inlineOutput . status ) . toBe ( 404 ) ;
147149 } ) ;
148150
149- it ( "rejects stored-payload pointers from public JWTs on trigger" , async ( ) => {
150- const { environment } = await seedTestEnvironment ( server . prisma ) ;
151- const jwt = await generateTestJWT ( environment , { scopes : [ "write:tasks:test-task" ] } ) ;
152-
153- const res = await server . webapp . fetch ( "/api/v1/tasks/test-task/trigger" , {
154- method : "POST" ,
155- headers : { Authorization : `Bearer ${ jwt } ` , "Content-Type" : "application/json" } ,
156- body : JSON . stringify ( {
157- payload : "run_victim/payload.json" ,
158- options : { payloadType : "application/store" } ,
159- } ) ,
160- } ) ;
161-
162- expect ( res . status ) . toBe ( 403 ) ;
163- } ) ;
164-
165- it ( "rejects stored-payload pointers from public JWTs on batch trigger" , async ( ) => {
166- const { environment } = await seedTestEnvironment ( server . prisma ) ;
167- const jwt = await generateTestJWT ( environment , { scopes : [ "write:tasks:test-task" ] } ) ;
151+ describe . each ( [
152+ "public JWT" ,
153+ "restricted API key" ,
154+ "root API key" ,
155+ "unrestricted API key" ,
156+ ] as const ) ( "%s stored pointers" , ( credential ) => {
157+ it . each ( [
158+ "trigger" ,
159+ "v1 batch" ,
160+ "v2 batch" ,
161+ "v3 items" ,
162+ // This legacy route does not accept public JWTs.
163+ ...( credential === "public JWT" ? [ ] : ( [ "task-scoped v1 batch" ] as const ) ) ,
164+ ] as const ) ( "enforces stored-pointer permission on %s" , async ( route ) => {
165+ const { environment, project } = await seedTestEnvironment ( server . prisma ) ;
166+ const { batchFriendlyId : seededBatchFriendlyId , run } = await seedTestRun ( server . prisma , {
167+ environmentId : environment . id ,
168+ projectId : project . id ,
169+ withBatch : route !== "v3 items" ,
170+ } ) ;
171+ let batchFriendlyId = seededBatchFriendlyId ;
172+ if ( route === "v3 items" ) {
173+ // Phase 1 initializes both the database record and Redis queue metadata.
174+ const response = await server . webapp . fetch ( "/api/v3/batches" , {
175+ method : "POST" ,
176+ headers : {
177+ Authorization : `Bearer ${ environment . apiKey } ` ,
178+ "Content-Type" : "application/json" ,
179+ } ,
180+ body : JSON . stringify ( { runCount : 1 , taskIdentifiers : [ "test-task" ] } ) ,
181+ } ) ;
182+ expect ( response . status ) . toBe ( 202 ) ;
183+ batchFriendlyId = ( await response . json ( ) ) . id ;
184+ } else {
185+ await server . prisma . batchTaskRun . update ( {
186+ where : { id : run . batchId ! } ,
187+ data : { status : "PENDING" , runCount : 1 } ,
188+ } ) ;
189+ }
190+ // Keep JSON-route controls independent of worker/deployment setup.
191+ // The payload gate must run even when a matching run is cached.
192+ const idempotencyKey = "stored-payload-authorization" ;
193+ await server . prisma . taskRun . update ( {
194+ where : { id : run . id } ,
195+ data : {
196+ idempotencyKey,
197+ idempotencyKeyExpiresAt : new Date ( Date . now ( ) + 3_600_000 ) ,
198+ } ,
199+ } ) ;
168200
169- const res = await server . webapp . fetch ( "/api/v2/tasks/batch" , {
170- method : "POST" ,
171- headers : { Authorization : `Bearer ${ jwt } ` , "Content-Type" : "application/json" } ,
172- body : JSON . stringify ( {
173- items : [
174- {
175- task : "test-task" ,
176- payload : "run_victim/payload.json" ,
177- options : { payloadType : "application/store" } ,
201+ let token : string ;
202+ if ( credential === "public JWT" ) {
203+ // Include a batch-wide grant to ensure it cannot bypass the payload gate.
204+ token = await generateTestJWT ( environment , {
205+ scopes : [ "write:tasks:test-task" , `write:batch:${ batchFriendlyId } ` ] ,
206+ } ) ;
207+ } else if ( credential === "root API key" ) {
208+ token = environment . apiKey ;
209+ } else {
210+ const user = await seedTestUser ( server . prisma ) ;
211+ token = generateAdditionalApiKey ( "DEVELOPMENT" ) . apiKey ;
212+ await server . prisma . apiKey . create ( {
213+ data : {
214+ name : "Task-scoped ingestion key" ,
215+ keyHash : createHash ( "sha256" ) . update ( token ) . digest ( "hex" ) ,
216+ lastFour : token . slice ( - 4 ) ,
217+ runtimeEnvironmentId : environment . id ,
218+ createdByUserId : user . id ,
219+ presetId : credential === "restricted API key" ? "TRIGGER_ONLY" : null ,
220+ scopes :
221+ credential === "restricted API key"
222+ ? [ "trigger:tasks:test-task" , "batchTrigger:tasks:test-task" ]
223+ : [ "admin" ] ,
178224 } ,
179- ] ,
180- } ) ,
181- } ) ;
225+ } ) ;
226+ }
227+
228+ const item = {
229+ task : "test-task" ,
230+ index : 0 ,
231+ payload : "run_other/payload.json" ,
232+ options : { payloadType : "application/store" , idempotencyKey } ,
233+ } ;
234+ const path =
235+ route === "trigger"
236+ ? "/api/v1/tasks/test-task/trigger"
237+ : route === "v3 items"
238+ ? `/api/v3/batches/${ batchFriendlyId } /items`
239+ : route === "task-scoped v1 batch"
240+ ? "/api/v1/tasks/test-task/batch"
241+ : `/api/${ route === "v1 batch" ? "v1" : "v2" } /tasks/batch` ;
242+ const res = await server . webapp . fetch ( path , {
243+ method : "POST" ,
244+ headers : {
245+ Authorization : `Bearer ${ token } ` ,
246+ "idempotency-key" : idempotencyKey ,
247+ "Content-Type" : route === "v3 items" ? "application/x-ndjson" : "application/json" ,
248+ } ,
249+ body :
250+ route === "v3 items"
251+ ? `${ JSON . stringify ( item ) } \n`
252+ : JSON . stringify ( route === "trigger" ? item : { items : [ item ] } ) ,
253+ } ) ;
182254
183- expect ( res . status ) . toBe ( 403 ) ;
255+ if ( credential === "public JWT" || credential === "restricted API key" ) {
256+ expect ( res . status ) . toBe ( 403 ) ;
257+ expect ( await res . json ( ) ) . toEqual ( {
258+ error :
259+ route === "v3 items"
260+ ? "Unauthorized"
261+ : "Stored payloads require an unrestricted private API key" ,
262+ } ) ;
263+ expect (
264+ await server . prisma . taskRun . count ( { where : { runtimeEnvironmentId : environment . id } } )
265+ ) . toBe ( 1 ) ;
266+ expect (
267+ await server . prisma . batchTaskRun . count ( {
268+ where : { runtimeEnvironmentId : environment . id } ,
269+ } )
270+ ) . toBe ( 1 ) ;
271+ expect (
272+ await server . prisma . batchTaskRun . findFirst ( { where : { friendlyId : batchFriendlyId } } )
273+ ) . toMatchObject ( { status : "PENDING" , sealed : false } ) ;
274+ if ( route === "task-scoped v1 batch" && credential === "restricted API key" ) {
275+ const inlineResponse = await server . webapp . fetch ( path , {
276+ method : "POST" ,
277+ headers : { Authorization : `Bearer ${ token } ` , "Content-Type" : "application/json" } ,
278+ body : JSON . stringify ( {
279+ items : [ { payload : { message : "inline" } , options : { idempotencyKey } } ] ,
280+ } ) ,
281+ } ) ;
282+ expect ( inlineResponse . status ) . toBe ( 200 ) ;
283+ expect ( await inlineResponse . json ( ) ) . toMatchObject ( {
284+ batchId : expect . stringMatching ( / ^ b a t c h _ / ) ,
285+ runs : [ run . friendlyId ] ,
286+ } ) ;
287+ }
288+ } else {
289+ expect ( res . status ) . toBe ( route === "v1 batch" || route === "v2 batch" ? 202 : 200 ) ;
290+ if ( route === "trigger" ) {
291+ expect ( await res . json ( ) ) . toMatchObject ( { id : run . friendlyId , isCached : true } ) ;
292+ } else if ( route === "v3 items" ) {
293+ expect ( await res . json ( ) ) . toMatchObject ( { itemsAccepted : 1 , sealed : true } ) ;
294+ } else if ( route === "task-scoped v1 batch" ) {
295+ expect ( await res . json ( ) ) . toMatchObject ( {
296+ batchId : expect . stringMatching ( / ^ b a t c h _ / ) ,
297+ runs : [ run . friendlyId ] ,
298+ } ) ;
299+ } else {
300+ expect ( await res . json ( ) ) . toMatchObject ( { id : expect . stringMatching ( / ^ b a t c h _ / ) } ) ;
301+ }
302+ }
303+ } ) ;
184304 } ) ;
185305} ) ;
186306
0 commit comments