@@ -1019,4 +1019,269 @@ describe("API", () => {
10191019 expect ( res . status ) . toBe ( 403 ) ;
10201020 } ) ;
10211021 } ) ;
1022+
1023+ // Run mutations (TRI-8735). Two routes:
1024+ // - POST /api/v2/runs/:runParam/cancel
1025+ // action: write, resource: { type: "runs", id: params.runParam }
1026+ // — single id-keyed resource, supports id-specific scopes.
1027+ // - POST /api/v1/idempotencyKeys/:key/reset
1028+ // action: write, resource: { type: "runs" } (collection-level)
1029+ // — id-specific scopes don't grant blanket access; only
1030+ // type-level write:runs (or super-scopes) work.
1031+ //
1032+ // The legacy idempotencyKeys/:key/reset rejected ALL JWTs due to an
1033+ // empty-resource bug. Post TRI-8719 the empty-resource resolution
1034+ // lets write:runs JWTs through. Tests here lock in the new behaviour.
1035+ describe ( "Run mutations — cancel (api.v2.runs.$runParam.cancel)" , ( ) => {
1036+ const pathFor = ( runId : string ) => `/api/v2/runs/${ runId } /cancel` ;
1037+ const post = ( path : string , headers : Record < string , string > ) =>
1038+ getTestServer ( ) . webapp . fetch ( path , {
1039+ method : "POST" ,
1040+ headers : { "Content-Type" : "application/json" , ...headers } ,
1041+ body : JSON . stringify ( { } ) ,
1042+ } ) ;
1043+
1044+ it ( "missing auth: 401" , async ( ) => {
1045+ const res = await post ( pathFor ( "run_anything" ) , { } ) ;
1046+ expect ( res . status ) . toBe ( 401 ) ;
1047+ } ) ;
1048+
1049+ it ( "invalid API key: 401" , async ( ) => {
1050+ const res = await post ( pathFor ( "run_anything" ) , {
1051+ Authorization : "Bearer tr_dev_definitely_not_real_key" ,
1052+ } ) ;
1053+ expect ( res . status ) . toBe ( 401 ) ;
1054+ } ) ;
1055+
1056+ it ( "private API key on real run: auth passes" , async ( ) => {
1057+ const server = getTestServer ( ) ;
1058+ const seed = await seedTestEnvironment ( server . prisma ) ;
1059+ const { runFriendlyId } = await seedTestRun ( server . prisma , {
1060+ environmentId : seed . environment . id ,
1061+ projectId : seed . project . id ,
1062+ } ) ;
1063+ const res = await post ( pathFor ( runFriendlyId ) , {
1064+ Authorization : `Bearer ${ seed . apiKey } ` ,
1065+ } ) ;
1066+ // Auth + findResource passed; handler may return any 2xx/4xx
1067+ // depending on run state. We only care: not 401/403.
1068+ expect ( res . status ) . not . toBe ( 401 ) ;
1069+ expect ( res . status ) . not . toBe ( 403 ) ;
1070+ } ) ;
1071+
1072+ it ( "JWT with write:runs (type-level): auth passes" , async ( ) => {
1073+ const server = getTestServer ( ) ;
1074+ const seed = await seedTestEnvironment ( server . prisma ) ;
1075+ const { runFriendlyId } = await seedTestRun ( server . prisma , {
1076+ environmentId : seed . environment . id ,
1077+ projectId : seed . project . id ,
1078+ } ) ;
1079+ const jwt = await generateJWT ( {
1080+ secretKey : seed . apiKey ,
1081+ payload : { pub : true , sub : seed . environment . id , scopes : [ "write:runs" ] } ,
1082+ expirationTime : "15m" ,
1083+ } ) ;
1084+ const res = await post ( pathFor ( runFriendlyId ) , {
1085+ Authorization : `Bearer ${ jwt } ` ,
1086+ } ) ;
1087+ expect ( res . status ) . not . toBe ( 401 ) ;
1088+ expect ( res . status ) . not . toBe ( 403 ) ;
1089+ } ) ;
1090+
1091+ it ( "JWT with write:runs:<exact runId>: auth passes" , async ( ) => {
1092+ const server = getTestServer ( ) ;
1093+ const seed = await seedTestEnvironment ( server . prisma ) ;
1094+ const { runFriendlyId } = await seedTestRun ( server . prisma , {
1095+ environmentId : seed . environment . id ,
1096+ projectId : seed . project . id ,
1097+ } ) ;
1098+ const jwt = await generateJWT ( {
1099+ secretKey : seed . apiKey ,
1100+ payload : {
1101+ pub : true ,
1102+ sub : seed . environment . id ,
1103+ scopes : [ `write:runs:${ runFriendlyId } ` ] ,
1104+ } ,
1105+ expirationTime : "15m" ,
1106+ } ) ;
1107+ const res = await post ( pathFor ( runFriendlyId ) , {
1108+ Authorization : `Bearer ${ jwt } ` ,
1109+ } ) ;
1110+ expect ( res . status ) . not . toBe ( 401 ) ;
1111+ expect ( res . status ) . not . toBe ( 403 ) ;
1112+ } ) ;
1113+
1114+ it ( "JWT with write:runs:<other>: 403" , async ( ) => {
1115+ const server = getTestServer ( ) ;
1116+ const seed = await seedTestEnvironment ( server . prisma ) ;
1117+ const { runFriendlyId } = await seedTestRun ( server . prisma , {
1118+ environmentId : seed . environment . id ,
1119+ projectId : seed . project . id ,
1120+ } ) ;
1121+ const jwt = await generateJWT ( {
1122+ secretKey : seed . apiKey ,
1123+ payload : {
1124+ pub : true ,
1125+ sub : seed . environment . id ,
1126+ scopes : [ "write:runs:run_someoneelse00000000000" ] ,
1127+ } ,
1128+ expirationTime : "15m" ,
1129+ } ) ;
1130+ const res = await post ( pathFor ( runFriendlyId ) , {
1131+ Authorization : `Bearer ${ jwt } ` ,
1132+ } ) ;
1133+ expect ( res . status ) . toBe ( 403 ) ;
1134+ } ) ;
1135+
1136+ it ( "JWT with read:runs (action mismatch): 403" , async ( ) => {
1137+ const server = getTestServer ( ) ;
1138+ const seed = await seedTestEnvironment ( server . prisma ) ;
1139+ const { runFriendlyId } = await seedTestRun ( server . prisma , {
1140+ environmentId : seed . environment . id ,
1141+ projectId : seed . project . id ,
1142+ } ) ;
1143+ const jwt = await generateJWT ( {
1144+ secretKey : seed . apiKey ,
1145+ payload : {
1146+ pub : true ,
1147+ sub : seed . environment . id ,
1148+ scopes : [ `read:runs:${ runFriendlyId } ` ] ,
1149+ } ,
1150+ expirationTime : "15m" ,
1151+ } ) ;
1152+ const res = await post ( pathFor ( runFriendlyId ) , {
1153+ Authorization : `Bearer ${ jwt } ` ,
1154+ } ) ;
1155+ expect ( res . status ) . toBe ( 403 ) ;
1156+ } ) ;
1157+
1158+ it ( "JWT with write:all super-scope: auth passes" , async ( ) => {
1159+ const server = getTestServer ( ) ;
1160+ const seed = await seedTestEnvironment ( server . prisma ) ;
1161+ const { runFriendlyId } = await seedTestRun ( server . prisma , {
1162+ environmentId : seed . environment . id ,
1163+ projectId : seed . project . id ,
1164+ } ) ;
1165+ const jwt = await generateJWT ( {
1166+ secretKey : seed . apiKey ,
1167+ payload : { pub : true , sub : seed . environment . id , scopes : [ "write:all" ] } ,
1168+ expirationTime : "15m" ,
1169+ } ) ;
1170+ const res = await post ( pathFor ( runFriendlyId ) , {
1171+ Authorization : `Bearer ${ jwt } ` ,
1172+ } ) ;
1173+ expect ( res . status ) . not . toBe ( 401 ) ;
1174+ expect ( res . status ) . not . toBe ( 403 ) ;
1175+ } ) ;
1176+
1177+ it ( "JWT with admin: auth passes" , async ( ) => {
1178+ const server = getTestServer ( ) ;
1179+ const seed = await seedTestEnvironment ( server . prisma ) ;
1180+ const { runFriendlyId } = await seedTestRun ( server . prisma , {
1181+ environmentId : seed . environment . id ,
1182+ projectId : seed . project . id ,
1183+ } ) ;
1184+ const jwt = await generateJWT ( {
1185+ secretKey : seed . apiKey ,
1186+ payload : { pub : true , sub : seed . environment . id , scopes : [ "admin" ] } ,
1187+ expirationTime : "15m" ,
1188+ } ) ;
1189+ const res = await post ( pathFor ( runFriendlyId ) , {
1190+ Authorization : `Bearer ${ jwt } ` ,
1191+ } ) ;
1192+ expect ( res . status ) . not . toBe ( 401 ) ;
1193+ expect ( res . status ) . not . toBe ( 403 ) ;
1194+ } ) ;
1195+ } ) ;
1196+
1197+ describe ( "Run mutations — idempotencyKeys.reset (api.v1.idempotencyKeys.$key.reset)" , ( ) => {
1198+ // Collection-level resource { type: "runs" } — id-specific
1199+ // write:runs:<runId> scopes don't help here (no id to match).
1200+ // The legacy version of this route rejected ALL JWTs due to an
1201+ // empty-resource bug; the post-TRI-8719 path lets write:runs
1202+ // through. Tests below pin that down.
1203+ const path = "/api/v1/idempotencyKeys/some-key/reset" ;
1204+ const validBody = JSON . stringify ( { taskIdentifier : "test-task" } ) ;
1205+
1206+ const post = ( headers : Record < string , string > , body = validBody ) =>
1207+ getTestServer ( ) . webapp . fetch ( path , {
1208+ method : "POST" ,
1209+ headers : { "Content-Type" : "application/json" , ...headers } ,
1210+ body,
1211+ } ) ;
1212+
1213+ it ( "missing auth: 401" , async ( ) => {
1214+ const res = await post ( { } ) ;
1215+ expect ( res . status ) . toBe ( 401 ) ;
1216+ } ) ;
1217+
1218+ it ( "invalid API key: 401" , async ( ) => {
1219+ const res = await post ( { Authorization : "Bearer tr_dev_invalid" } ) ;
1220+ expect ( res . status ) . toBe ( 401 ) ;
1221+ } ) ;
1222+
1223+ it ( "private API key: auth passes" , async ( ) => {
1224+ const server = getTestServer ( ) ;
1225+ const seed = await seedTestEnvironment ( server . prisma ) ;
1226+ const res = await post ( { Authorization : `Bearer ${ seed . apiKey } ` } ) ;
1227+ // Handler may 404/204 depending on whether the idempotency key
1228+ // exists. Auth-passed assertion only.
1229+ expect ( res . status ) . not . toBe ( 401 ) ;
1230+ expect ( res . status ) . not . toBe ( 403 ) ;
1231+ } ) ;
1232+
1233+ it ( "JWT with write:runs (type-level): auth passes — locks in TRI-8719 fix" , async ( ) => {
1234+ const server = getTestServer ( ) ;
1235+ const seed = await seedTestEnvironment ( server . prisma ) ;
1236+ const jwt = await generateJWT ( {
1237+ secretKey : seed . apiKey ,
1238+ payload : { pub : true , sub : seed . environment . id , scopes : [ "write:runs" ] } ,
1239+ expirationTime : "15m" ,
1240+ } ) ;
1241+ const res = await post ( { Authorization : `Bearer ${ jwt } ` } ) ;
1242+ // PRE-TRI-8719: this returned 403 (legacy empty-resource bug
1243+ // rejected all JWTs). POST-TRI-8719: write:runs grants access.
1244+ // Locking in the new behaviour.
1245+ expect ( res . status ) . not . toBe ( 401 ) ;
1246+ expect ( res . status ) . not . toBe ( 403 ) ;
1247+ } ) ;
1248+
1249+ it ( "JWT with read:runs (action mismatch): 403" , async ( ) => {
1250+ const server = getTestServer ( ) ;
1251+ const seed = await seedTestEnvironment ( server . prisma ) ;
1252+ const jwt = await generateJWT ( {
1253+ secretKey : seed . apiKey ,
1254+ payload : { pub : true , sub : seed . environment . id , scopes : [ "read:runs" ] } ,
1255+ expirationTime : "15m" ,
1256+ } ) ;
1257+ const res = await post ( { Authorization : `Bearer ${ jwt } ` } ) ;
1258+ expect ( res . status ) . toBe ( 403 ) ;
1259+ } ) ;
1260+
1261+ it ( "JWT with write:all: auth passes" , async ( ) => {
1262+ const server = getTestServer ( ) ;
1263+ const seed = await seedTestEnvironment ( server . prisma ) ;
1264+ const jwt = await generateJWT ( {
1265+ secretKey : seed . apiKey ,
1266+ payload : { pub : true , sub : seed . environment . id , scopes : [ "write:all" ] } ,
1267+ expirationTime : "15m" ,
1268+ } ) ;
1269+ const res = await post ( { Authorization : `Bearer ${ jwt } ` } ) ;
1270+ expect ( res . status ) . not . toBe ( 401 ) ;
1271+ expect ( res . status ) . not . toBe ( 403 ) ;
1272+ } ) ;
1273+
1274+ it ( "JWT with admin: auth passes" , async ( ) => {
1275+ const server = getTestServer ( ) ;
1276+ const seed = await seedTestEnvironment ( server . prisma ) ;
1277+ const jwt = await generateJWT ( {
1278+ secretKey : seed . apiKey ,
1279+ payload : { pub : true , sub : seed . environment . id , scopes : [ "admin" ] } ,
1280+ expirationTime : "15m" ,
1281+ } ) ;
1282+ const res = await post ( { Authorization : `Bearer ${ jwt } ` } ) ;
1283+ expect ( res . status ) . not . toBe ( 401 ) ;
1284+ expect ( res . status ) . not . toBe ( 403 ) ;
1285+ } ) ;
1286+ } ) ;
10221287} ) ;
0 commit comments