@@ -744,13 +744,16 @@ describe("API", () => {
744744 } ) ;
745745 } ) ;
746746
747- // v3 batches use a collection-level resource { type: "tasks" } with
748- // no id — items are validated per-row when streamed. So id-specific
749- // scopes (write:tasks:foo) shouldn't grant blanket access; only
750- // type-level write:tasks (or admin/write:all) should .
751- describe ( "Trigger task — batch v3 (api.v3.batches) collection-level " , ( ) => {
747+ // v3 batch creation accepts an optional declaration of the distinct task
748+ // identifiers that will be streamed. New clients send it so selected-task
749+ // credentials can authorize the batch before its shell is created. Older
750+ // clients omit it and retain the collection-level, fail-closed behavior .
751+ describe ( "Trigger task — batch v3 (api.v3.batches)" , ( ) => {
752752 const path = "/api/v3/batches" ;
753- const buildBody = ( ) => ( { runCount : 1 } ) ;
753+ const buildBody = ( taskIdentifiers ?: string [ ] ) => ( {
754+ runCount : taskIdentifiers ?. length ?? 1 ,
755+ taskIdentifiers,
756+ } ) ;
754757
755758 it ( "missing auth: 401" , async ( ) => {
756759 const server = getTestServer ( ) ;
@@ -779,6 +782,67 @@ describe("API", () => {
779782 expect ( res . status ) . not . toBe ( 403 ) ;
780783 } ) ;
781784
785+ it ( "JWT with batchTrigger:tasks:taskA + declared taskA: auth passes" , async ( ) => {
786+ const server = getTestServer ( ) ;
787+ const seed = await seedTestEnvironment ( server . prisma ) ;
788+ const jwt = await generateJWT ( {
789+ secretKey : seed . apiKey ,
790+ payload : {
791+ pub : true ,
792+ sub : seed . environment . id ,
793+ scopes : [ "batchTrigger:tasks:taskA" ] ,
794+ } ,
795+ expirationTime : "15m" ,
796+ } ) ;
797+ const res = await server . webapp . fetch ( path , {
798+ method : "POST" ,
799+ headers : { Authorization : `Bearer ${ jwt } ` , "Content-Type" : "application/json" } ,
800+ body : JSON . stringify ( buildBody ( [ "taskA" ] ) ) ,
801+ } ) ;
802+ expect ( res . status ) . not . toBe ( 401 ) ;
803+ expect ( res . status ) . not . toBe ( 403 ) ;
804+ } ) ;
805+
806+ it ( "JWT with batchTrigger:tasks:taskA + a mixed declaration: 403" , async ( ) => {
807+ const server = getTestServer ( ) ;
808+ const seed = await seedTestEnvironment ( server . prisma ) ;
809+ const jwt = await generateJWT ( {
810+ secretKey : seed . apiKey ,
811+ payload : {
812+ pub : true ,
813+ sub : seed . environment . id ,
814+ scopes : [ "batchTrigger:tasks:taskA" ] ,
815+ } ,
816+ expirationTime : "15m" ,
817+ } ) ;
818+ const res = await server . webapp . fetch ( path , {
819+ method : "POST" ,
820+ headers : { Authorization : `Bearer ${ jwt } ` , "Content-Type" : "application/json" } ,
821+ body : JSON . stringify ( buildBody ( [ "taskA" , "taskB" ] ) ) ,
822+ } ) ;
823+ expect ( res . status ) . toBe ( 403 ) ;
824+ } ) ;
825+
826+ it ( "JWT with batchTrigger:tasks:taskA + no declaration: 403" , async ( ) => {
827+ const server = getTestServer ( ) ;
828+ const seed = await seedTestEnvironment ( server . prisma ) ;
829+ const jwt = await generateJWT ( {
830+ secretKey : seed . apiKey ,
831+ payload : {
832+ pub : true ,
833+ sub : seed . environment . id ,
834+ scopes : [ "batchTrigger:tasks:taskA" ] ,
835+ } ,
836+ expirationTime : "15m" ,
837+ } ) ;
838+ const res = await server . webapp . fetch ( path , {
839+ method : "POST" ,
840+ headers : { Authorization : `Bearer ${ jwt } ` , "Content-Type" : "application/json" } ,
841+ body : JSON . stringify ( buildBody ( ) ) ,
842+ } ) ;
843+ expect ( res . status ) . toBe ( 403 ) ;
844+ } ) ;
845+
782846 it ( "JWT with read:tasks: 403" , async ( ) => {
783847 const server = getTestServer ( ) ;
784848 const seed = await seedTestEnvironment ( server . prisma ) ;
0 commit comments