@@ -1539,4 +1539,225 @@ describe("API", () => {
15391539 expect ( res . status ) . not . toBe ( 403 ) ;
15401540 } ) ;
15411541 } ) ;
1542+
1543+ // Batch resources (TRI-8737). Per-batch retrieve + realtime
1544+ // endpoints — single-id resource `{ type: "batch", id: batch.friendlyId }`.
1545+ // The list endpoint (`GET /api/v1/batches`) is currently absent
1546+ // from this branch (deleted in s3-switchover), so the list-
1547+ // section of the matrix is N/A here. If/when the list endpoint
1548+ // returns, add a list-side describe.
1549+ //
1550+ // Notable behaviour: the route's resource is `{ type: "batch" }`,
1551+ // NOT `{ type: "runs" }`. The legacy literal-match escape that
1552+ // let `read:runs` JWTs hit batch endpoints no longer applies.
1553+ // Tests pin this down (a `read:runs` scope on a `{ type: "batch" }`
1554+ // resource is a type mismatch → 403).
1555+ describe ( "Batch retrieve — GET /api/v1/batches/:batchId" , ( ) => {
1556+ const pathFor = ( batchId : string ) => `/api/v1/batches/${ batchId } ` ;
1557+
1558+ async function seedRunWithBatch ( ) {
1559+ const server = getTestServer ( ) ;
1560+ const seed = await seedTestEnvironment ( server . prisma ) ;
1561+ const seeded = await seedTestRun ( server . prisma , {
1562+ environmentId : seed . environment . id ,
1563+ projectId : seed . project . id ,
1564+ withBatch : true ,
1565+ } ) ;
1566+ // batchFriendlyId is guaranteed when withBatch is set.
1567+ if ( ! seeded . batchFriendlyId ) {
1568+ throw new Error ( "seedTestRun({ withBatch: true }) didn't return a batchFriendlyId" ) ;
1569+ }
1570+ return { ...seed , batchFriendlyId : seeded . batchFriendlyId } ;
1571+ }
1572+
1573+ const get = ( path : string , headers : Record < string , string > ) =>
1574+ getTestServer ( ) . webapp . fetch ( path , { headers } ) ;
1575+
1576+ it ( "missing auth: 401" , async ( ) => {
1577+ const res = await get ( pathFor ( "batch_anything" ) , { } ) ;
1578+ expect ( res . status ) . toBe ( 401 ) ;
1579+ } ) ;
1580+
1581+ it ( "invalid API key: 401" , async ( ) => {
1582+ const res = await get ( pathFor ( "batch_anything" ) , {
1583+ Authorization : "Bearer tr_dev_invalid" ,
1584+ } ) ;
1585+ expect ( res . status ) . toBe ( 401 ) ;
1586+ } ) ;
1587+
1588+ it ( "private API key on real batch: auth passes" , async ( ) => {
1589+ const { batchFriendlyId, apiKey } = await seedRunWithBatch ( ) ;
1590+ const res = await get ( pathFor ( batchFriendlyId ) , {
1591+ Authorization : `Bearer ${ apiKey } ` ,
1592+ } ) ;
1593+ expect ( res . status ) . not . toBe ( 401 ) ;
1594+ expect ( res . status ) . not . toBe ( 403 ) ;
1595+ } ) ;
1596+
1597+ it ( "JWT read:batch:<friendlyId> matching: auth passes" , async ( ) => {
1598+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch ( ) ;
1599+ const jwt = await generateJWT ( {
1600+ secretKey : apiKey ,
1601+ payload : {
1602+ pub : true ,
1603+ sub : environment . id ,
1604+ scopes : [ `read:batch:${ batchFriendlyId } ` ] ,
1605+ } ,
1606+ expirationTime : "15m" ,
1607+ } ) ;
1608+ const res = await get ( pathFor ( batchFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1609+ expect ( res . status ) . not . toBe ( 401 ) ;
1610+ expect ( res . status ) . not . toBe ( 403 ) ;
1611+ } ) ;
1612+
1613+ it ( "JWT read:batch:<other>: 403" , async ( ) => {
1614+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch ( ) ;
1615+ const jwt = await generateJWT ( {
1616+ secretKey : apiKey ,
1617+ payload : {
1618+ pub : true ,
1619+ sub : environment . id ,
1620+ scopes : [ "read:batch:batch_someoneelse00000000" ] ,
1621+ } ,
1622+ expirationTime : "15m" ,
1623+ } ) ;
1624+ const res = await get ( pathFor ( batchFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1625+ expect ( res . status ) . toBe ( 403 ) ;
1626+ } ) ;
1627+
1628+ it ( "JWT read:batch (type-level): auth passes" , async ( ) => {
1629+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch ( ) ;
1630+ const jwt = await generateJWT ( {
1631+ secretKey : apiKey ,
1632+ payload : { pub : true , sub : environment . id , scopes : [ "read:batch" ] } ,
1633+ expirationTime : "15m" ,
1634+ } ) ;
1635+ const res = await get ( pathFor ( batchFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1636+ expect ( res . status ) . not . toBe ( 401 ) ;
1637+ expect ( res . status ) . not . toBe ( 403 ) ;
1638+ } ) ;
1639+
1640+ it ( "JWT read:runs: 403 (resource type is 'batch', not 'runs')" , async ( ) => {
1641+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch ( ) ;
1642+ const jwt = await generateJWT ( {
1643+ secretKey : apiKey ,
1644+ payload : { pub : true , sub : environment . id , scopes : [ "read:runs" ] } ,
1645+ expirationTime : "15m" ,
1646+ } ) ;
1647+ const res = await get ( pathFor ( batchFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1648+ // Pre-TRI-8719 the legacy literal-match escape granted
1649+ // read:runs access to batch endpoints. Post-migration the
1650+ // resource type is strictly { type: "batch" } and read:runs
1651+ // doesn't match. Lock this in — if SDKs were issuing
1652+ // read:runs:* JWTs for batch lookups, that's a regression to
1653+ // catch.
1654+ expect ( res . status ) . toBe ( 403 ) ;
1655+ } ) ;
1656+
1657+ it ( "JWT read:all super-scope: auth passes" , async ( ) => {
1658+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch ( ) ;
1659+ const jwt = await generateJWT ( {
1660+ secretKey : apiKey ,
1661+ payload : { pub : true , sub : environment . id , scopes : [ "read:all" ] } ,
1662+ expirationTime : "15m" ,
1663+ } ) ;
1664+ const res = await get ( pathFor ( batchFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1665+ expect ( res . status ) . not . toBe ( 401 ) ;
1666+ expect ( res . status ) . not . toBe ( 403 ) ;
1667+ } ) ;
1668+
1669+ it ( "JWT admin: auth passes" , async ( ) => {
1670+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch ( ) ;
1671+ const jwt = await generateJWT ( {
1672+ secretKey : apiKey ,
1673+ payload : { pub : true , sub : environment . id , scopes : [ "admin" ] } ,
1674+ expirationTime : "15m" ,
1675+ } ) ;
1676+ const res = await get ( pathFor ( batchFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1677+ expect ( res . status ) . not . toBe ( 401 ) ;
1678+ expect ( res . status ) . not . toBe ( 403 ) ;
1679+ } ) ;
1680+
1681+ it ( "cross-env: env A's JWT cannot read env B's batch: not 200" , async ( ) => {
1682+ const server = getTestServer ( ) ;
1683+ const a = await seedTestEnvironment ( server . prisma ) ;
1684+ const b = await seedRunWithBatch ( ) ;
1685+ const jwt = await generateJWT ( {
1686+ secretKey : a . apiKey ,
1687+ payload : {
1688+ pub : true ,
1689+ sub : a . environment . id ,
1690+ scopes : [ `read:batch:${ b . batchFriendlyId } ` ] ,
1691+ } ,
1692+ expirationTime : "15m" ,
1693+ } ) ;
1694+ const res = await get ( pathFor ( b . batchFriendlyId ) , { Authorization : `Bearer ${ jwt } ` } ) ;
1695+ // Critical: env A's JWT can't see env B's batch (env-scoped
1696+ // findResource returns null). NOT 200.
1697+ expect ( res . status ) . not . toBe ( 200 ) ;
1698+ } ) ;
1699+ } ) ;
1700+
1701+ // Sanity: api.v2 and realtime.v1 share the exact same authorization
1702+ // config as v1. Don't duplicate the full matrix; just verify the
1703+ // wiring is alive on each.
1704+ describe ( "Batch retrieve — GET /api/v2/batches/:batchId (sanity)" , ( ) => {
1705+ it ( "missing auth: 401" , async ( ) => {
1706+ const res = await getTestServer ( ) . webapp . fetch ( "/api/v2/batches/batch_anything" ) ;
1707+ expect ( res . status ) . toBe ( 401 ) ;
1708+ } ) ;
1709+
1710+ it ( "JWT read:batch (type-level): auth passes on real batch" , async ( ) => {
1711+ const server = getTestServer ( ) ;
1712+ const seed = await seedTestEnvironment ( server . prisma ) ;
1713+ const seeded = await seedTestRun ( server . prisma , {
1714+ environmentId : seed . environment . id ,
1715+ projectId : seed . project . id ,
1716+ withBatch : true ,
1717+ } ) ;
1718+ const jwt = await generateJWT ( {
1719+ secretKey : seed . apiKey ,
1720+ payload : { pub : true , sub : seed . environment . id , scopes : [ "read:batch" ] } ,
1721+ expirationTime : "15m" ,
1722+ } ) ;
1723+ const res = await getTestServer ( ) . webapp . fetch (
1724+ `/api/v2/batches/${ seeded . batchFriendlyId } ` ,
1725+ { headers : { Authorization : `Bearer ${ jwt } ` } }
1726+ ) ;
1727+ expect ( res . status ) . not . toBe ( 401 ) ;
1728+ expect ( res . status ) . not . toBe ( 403 ) ;
1729+ } ) ;
1730+ } ) ;
1731+
1732+ describe ( "Batch retrieve — GET /realtime/v1/batches/:batchId (sanity)" , ( ) => {
1733+ it ( "missing auth: 401" , async ( ) => {
1734+ const res = await getTestServer ( ) . webapp . fetch ( "/realtime/v1/batches/batch_anything" ) ;
1735+ expect ( res . status ) . toBe ( 401 ) ;
1736+ } ) ;
1737+
1738+ it ( "JWT read:batch:<friendlyId>: auth passes on real batch" , async ( ) => {
1739+ const server = getTestServer ( ) ;
1740+ const seed = await seedTestEnvironment ( server . prisma ) ;
1741+ const seeded = await seedTestRun ( server . prisma , {
1742+ environmentId : seed . environment . id ,
1743+ projectId : seed . project . id ,
1744+ withBatch : true ,
1745+ } ) ;
1746+ const jwt = await generateJWT ( {
1747+ secretKey : seed . apiKey ,
1748+ payload : {
1749+ pub : true ,
1750+ sub : seed . environment . id ,
1751+ scopes : [ `read:batch:${ seeded . batchFriendlyId } ` ] ,
1752+ } ,
1753+ expirationTime : "15m" ,
1754+ } ) ;
1755+ const res = await getTestServer ( ) . webapp . fetch (
1756+ `/realtime/v1/batches/${ seeded . batchFriendlyId } ` ,
1757+ { headers : { Authorization : `Bearer ${ jwt } ` } }
1758+ ) ;
1759+ expect ( res . status ) . not . toBe ( 401 ) ;
1760+ expect ( res . status ) . not . toBe ( 403 ) ;
1761+ } ) ;
1762+ } ) ;
15421763} ) ;
0 commit comments