@@ -1284,4 +1284,259 @@ describe("API", () => {
12841284 expect ( res . status ) . not . toBe ( 403 ) ;
12851285 } ) ;
12861286 } ) ;
1287+
1288+ // Run resource routes (TRI-8734). Every read-side `$runId` route
1289+ // computes its authorization resource from the loaded TaskRun:
1290+ // [
1291+ // { type: "runs", id: run.friendlyId },
1292+ // { type: "tasks", id: run.taskIdentifier },
1293+ // ...run.runTags.map(tag => ({ type: "tags", id: tag })),
1294+ // run.batch?.friendlyId && { type: "batch", id: run.batch.friendlyId },
1295+ // ]
1296+ //
1297+ // A JWT scope matching ANY array element grants access. We test the
1298+ // full matrix against the canonical route (api.v3.runs.$runId), and
1299+ // a sanity check on one of the others to confirm the wiring isn't
1300+ // route-local. If a future route's resource shape diverges, add a
1301+ // targeted describe.
1302+ describe ( "Run resource — GET /api/v3/runs/:runId (multi-key array)" , ( ) => {
1303+ const pathFor = ( runId : string ) => `/api/v3/runs/${ runId } ` ;
1304+
1305+ async function seedRunWithBatchAndTags ( ) {
1306+ const server = getTestServer ( ) ;
1307+ const seed = await seedTestEnvironment ( server . prisma ) ;
1308+ const seeded = await seedTestRun ( server . prisma , {
1309+ environmentId : seed . environment . id ,
1310+ projectId : seed . project . id ,
1311+ runTags : [ "alpha" , "beta" ] ,
1312+ withBatch : true ,
1313+ } ) ;
1314+ return { ...seed , ...seeded } ;
1315+ }
1316+
1317+ const get = ( path : string , headers : Record < string , string > ) =>
1318+ getTestServer ( ) . webapp . fetch ( path , { headers } ) ;
1319+
1320+ it ( "missing auth: 401" , async ( ) => {
1321+ const res = await get ( pathFor ( "run_anything" ) , { } ) ;
1322+ expect ( res . status ) . toBe ( 401 ) ;
1323+ } ) ;
1324+
1325+ it ( "invalid API key: 401" , async ( ) => {
1326+ const res = await get ( pathFor ( "run_anything" ) , {
1327+ Authorization : "Bearer tr_dev_invalid" ,
1328+ } ) ;
1329+ expect ( res . status ) . toBe ( 401 ) ;
1330+ } ) ;
1331+
1332+ it ( "private API key on real run: auth passes" , async ( ) => {
1333+ const { runFriendlyId, apiKey } = await seedRunWithBatchAndTags ( ) ;
1334+ const res = await get ( pathFor ( runFriendlyId ) , {
1335+ Authorization : `Bearer ${ apiKey } ` ,
1336+ } ) ;
1337+ expect ( res . status ) . not . toBe ( 401 ) ;
1338+ expect ( res . status ) . not . toBe ( 403 ) ;
1339+ } ) ;
1340+
1341+ it ( "JWT read:runs (type-level): auth passes" , async ( ) => {
1342+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1343+ const jwt = await generateJWT ( {
1344+ secretKey : apiKey ,
1345+ payload : { pub : true , sub : environment . id , scopes : [ "read:runs" ] } ,
1346+ expirationTime : "15m" ,
1347+ } ) ;
1348+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1349+ expect ( res . status ) . not . toBe ( 401 ) ;
1350+ expect ( res . status ) . not . toBe ( 403 ) ;
1351+ } ) ;
1352+
1353+ it ( "JWT read:runs:<exact friendlyId>: auth passes (id match)" , async ( ) => {
1354+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1355+ const jwt = await generateJWT ( {
1356+ secretKey : apiKey ,
1357+ payload : {
1358+ pub : true ,
1359+ sub : environment . id ,
1360+ scopes : [ `read:runs:${ runFriendlyId } ` ] ,
1361+ } ,
1362+ expirationTime : "15m" ,
1363+ } ) ;
1364+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1365+ expect ( res . status ) . not . toBe ( 401 ) ;
1366+ expect ( res . status ) . not . toBe ( 403 ) ;
1367+ } ) ;
1368+
1369+ it ( "JWT read:runs:<other>: 403" , async ( ) => {
1370+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1371+ const jwt = await generateJWT ( {
1372+ secretKey : apiKey ,
1373+ payload : {
1374+ pub : true ,
1375+ sub : environment . id ,
1376+ scopes : [ "read:runs:run_someoneelse00000000000" ] ,
1377+ } ,
1378+ expirationTime : "15m" ,
1379+ } ) ;
1380+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1381+ expect ( res . status ) . toBe ( 403 ) ;
1382+ } ) ;
1383+
1384+ it ( "JWT read:tags:<tag the run has>: auth passes (array element match)" , async ( ) => {
1385+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1386+ // run was seeded with runTags=["alpha","beta"]; scope matches "alpha".
1387+ const jwt = await generateJWT ( {
1388+ secretKey : apiKey ,
1389+ payload : { pub : true , sub : environment . id , scopes : [ "read:tags:alpha" ] } ,
1390+ expirationTime : "15m" ,
1391+ } ) ;
1392+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1393+ expect ( res . status ) . not . toBe ( 401 ) ;
1394+ expect ( res . status ) . not . toBe ( 403 ) ;
1395+ } ) ;
1396+
1397+ it ( "JWT read:tags:<tag the run does not have>: 403" , async ( ) => {
1398+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1399+ const jwt = await generateJWT ( {
1400+ secretKey : apiKey ,
1401+ payload : { pub : true , sub : environment . id , scopes : [ "read:tags:gamma" ] } ,
1402+ expirationTime : "15m" ,
1403+ } ) ;
1404+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1405+ expect ( res . status ) . toBe ( 403 ) ;
1406+ } ) ;
1407+
1408+ it ( "JWT read:batch:<run's batchFriendlyId>: auth passes" , async ( ) => {
1409+ const { runFriendlyId, batchFriendlyId, apiKey, environment } =
1410+ await seedRunWithBatchAndTags ( ) ;
1411+ const jwt = await generateJWT ( {
1412+ secretKey : apiKey ,
1413+ payload : {
1414+ pub : true ,
1415+ sub : environment . id ,
1416+ scopes : [ `read:batch:${ batchFriendlyId } ` ] ,
1417+ } ,
1418+ expirationTime : "15m" ,
1419+ } ) ;
1420+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1421+ expect ( res . status ) . not . toBe ( 401 ) ;
1422+ expect ( res . status ) . not . toBe ( 403 ) ;
1423+ } ) ;
1424+
1425+ it ( "JWT read:batch:<other>: 403" , async ( ) => {
1426+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1427+ const jwt = await generateJWT ( {
1428+ secretKey : apiKey ,
1429+ payload : {
1430+ pub : true ,
1431+ sub : environment . id ,
1432+ scopes : [ "read:batch:batch_someoneelse00000000" ] ,
1433+ } ,
1434+ expirationTime : "15m" ,
1435+ } ) ;
1436+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1437+ expect ( res . status ) . toBe ( 403 ) ;
1438+ } ) ;
1439+
1440+ it ( "JWT read:tasks:<run's taskIdentifier>: auth passes" , async ( ) => {
1441+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1442+ // seedTestRun uses taskIdentifier "test-task" by default.
1443+ const jwt = await generateJWT ( {
1444+ secretKey : apiKey ,
1445+ payload : { pub : true , sub : environment . id , scopes : [ "read:tasks:test-task" ] } ,
1446+ expirationTime : "15m" ,
1447+ } ) ;
1448+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1449+ expect ( res . status ) . not . toBe ( 401 ) ;
1450+ expect ( res . status ) . not . toBe ( 403 ) ;
1451+ } ) ;
1452+
1453+ it ( "JWT read:all: auth passes" , async ( ) => {
1454+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1455+ const jwt = await generateJWT ( {
1456+ secretKey : apiKey ,
1457+ payload : { pub : true , sub : environment . id , scopes : [ "read:all" ] } ,
1458+ expirationTime : "15m" ,
1459+ } ) ;
1460+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1461+ expect ( res . status ) . not . toBe ( 401 ) ;
1462+ expect ( res . status ) . not . toBe ( 403 ) ;
1463+ } ) ;
1464+
1465+ it ( "JWT admin: auth passes" , async ( ) => {
1466+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1467+ const jwt = await generateJWT ( {
1468+ secretKey : apiKey ,
1469+ payload : { pub : true , sub : environment . id , scopes : [ "admin" ] } ,
1470+ expirationTime : "15m" ,
1471+ } ) ;
1472+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1473+ expect ( res . status ) . not . toBe ( 401 ) ;
1474+ expect ( res . status ) . not . toBe ( 403 ) ;
1475+ } ) ;
1476+
1477+ it ( "JWT write:runs:<friendlyId>: 403 (action mismatch — read route)" , async ( ) => {
1478+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags ( ) ;
1479+ const jwt = await generateJWT ( {
1480+ secretKey : apiKey ,
1481+ payload : {
1482+ pub : true ,
1483+ sub : environment . id ,
1484+ scopes : [ `write:runs:${ runFriendlyId } ` ] ,
1485+ } ,
1486+ expirationTime : "15m" ,
1487+ } ) ;
1488+ const res = await get ( pathFor ( runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1489+ expect ( res . status ) . toBe ( 403 ) ;
1490+ } ) ;
1491+
1492+ it ( "cross-env: env A's JWT cannot read env B's run: not 200" , async ( ) => {
1493+ const server = getTestServer ( ) ;
1494+ const a = await seedTestEnvironment ( server . prisma ) ;
1495+ const b = await seedRunWithBatchAndTags ( ) ;
1496+ const jwt = await generateJWT ( {
1497+ secretKey : a . apiKey ,
1498+ payload : {
1499+ pub : true ,
1500+ sub : a . environment . id ,
1501+ scopes : [ `read:runs:${ b . runFriendlyId } ` ] ,
1502+ } ,
1503+ expirationTime : "15m" ,
1504+ } ) ;
1505+ const res = await get ( pathFor ( b . runFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1506+ // Either auth fails or the run lookup misses (env A's view of
1507+ // the run doesn't include env B's data). Critical: NOT 200.
1508+ expect ( res . status ) . not . toBe ( 200 ) ;
1509+ } ) ;
1510+ } ) ;
1511+
1512+ // Sanity check: same multi-key pattern wired the same way on the
1513+ // events sub-route. If this drifts in the future the divergence
1514+ // gets a dedicated describe.
1515+ describe ( "Run resource — GET /api/v1/runs/:runId/events (sanity)" , ( ) => {
1516+ const pathFor = ( runId : string ) => `/api/v1/runs/${ runId } /events` ;
1517+
1518+ it ( "missing auth: 401" , async ( ) => {
1519+ const res = await getTestServer ( ) . webapp . fetch ( pathFor ( "run_anything" ) ) ;
1520+ expect ( res . status ) . toBe ( 401 ) ;
1521+ } ) ;
1522+
1523+ it ( "JWT read:runs (type-level): auth passes on a real run" , async ( ) => {
1524+ const server = getTestServer ( ) ;
1525+ const seed = await seedTestEnvironment ( server . prisma ) ;
1526+ const { runFriendlyId } = await seedTestRun ( server . prisma , {
1527+ environmentId : seed . environment . id ,
1528+ projectId : seed . project . id ,
1529+ } ) ;
1530+ const jwt = await generateJWT ( {
1531+ secretKey : seed . apiKey ,
1532+ payload : { pub : true , sub : seed . environment . id , scopes : [ "read:runs" ] } ,
1533+ expirationTime : "15m" ,
1534+ } ) ;
1535+ const res = await getTestServer ( ) . webapp . fetch ( pathFor ( runFriendlyId ) , {
1536+ headers : { Authorization : `Bearer ${ jwt } ` } ,
1537+ } ) ;
1538+ expect ( res . status ) . not . toBe ( 401 ) ;
1539+ expect ( res . status ) . not . toBe ( 403 ) ;
1540+ } ) ;
1541+ } ) ;
12871542} ) ;
0 commit comments