@@ -8,11 +8,13 @@ import openApiSpec from './openapi.json';
88// Mock viem
99const mockReadContract = vi . fn ( ) ;
1010const mockGetChainId = vi . fn ( ) ;
11+ const mockGetBlockNumber = vi . fn ( ) ;
1112
1213vi . mock ( 'viem' , ( ) => ( {
1314 createPublicClient : vi . fn ( ( ) => ( {
1415 readContract : mockReadContract ,
1516 getChainId : mockGetChainId ,
17+ getBlockNumber : mockGetBlockNumber ,
1618 } ) ) ,
1719 http : vi . fn ( ) ,
1820 getContract : vi . fn ( ) ,
@@ -26,6 +28,8 @@ beforeAll(async () => {
2628 process . env . ETH_RPC_URL = 'http://localhost:8545' ;
2729 process . env . KMS_CONTRACT_ADDR = '0x1234567890123456789012345678901234567890' ;
2830 process . env . PORT = '3001' ;
31+ process . env . ETH_CHAIN_ID = '1337' ;
32+ process . env . ETH_FINALITY_CONFIRMATIONS = '2' ;
2933
3034 // Import the app after mocking
3135 const indexModule = await import ( './index.ts' ) ;
@@ -35,6 +39,8 @@ beforeAll(async () => {
3539beforeEach ( ( ) => {
3640 // Reset mocks before each test
3741 vi . clearAllMocks ( ) ;
42+ mockGetChainId . mockResolvedValue ( 1337 ) ;
43+ mockGetBlockNumber . mockResolvedValue ( 100n ) ;
3844} ) ;
3945
4046describe ( 'API Compatibility Tests' , ( ) => {
@@ -317,8 +323,9 @@ describe('API Compatibility Tests', () => {
317323 expect ( data . isAllowed ) . toBe ( false ) ;
318324 expect ( data . reason ) . toBe ( 'authorization backend unavailable' ) ;
319325
320- // Verify that console.error was called for real errors
321- expect ( consoleSpy ) . toHaveBeenCalledWith ( 'error in KMS boot auth:' , expect . any ( Error ) ) ;
326+ // Diagnostics identify the failing boundary without retaining backend details.
327+ expect ( consoleSpy ) . toHaveBeenCalledWith ( 'KMS authorization backend failed' ) ;
328+ expect ( JSON . stringify ( consoleSpy . mock . calls ) ) . not . toContain ( 'real error' ) ;
322329
323330 consoleSpy . mockRestore ( ) ;
324331 } ) ;
@@ -403,3 +410,158 @@ describe('Hex Decoding Compatibility', () => {
403410 expect ( response . status ) . toBe ( 200 ) ;
404411 } ) ;
405412} ) ;
413+
414+ describe ( 'Authorization freshness and domain binding' , ( ) => {
415+ const requestBody = {
416+ mrAggregated : '0x' + '11' . repeat ( 32 ) ,
417+ osImageHash : '0x' + '22' . repeat ( 32 ) ,
418+ appId : '0x' + '33' . repeat ( 20 ) ,
419+ composeHash : '0x' + '44' . repeat ( 32 ) ,
420+ instanceId : '0x' + '55' . repeat ( 20 ) ,
421+ deviceId : '0x' + '66' . repeat ( 32 ) ,
422+ } ;
423+
424+ const postApp = ( body = requestBody ) => appFetch ( new Request (
425+ 'http://localhost:3001/bootAuth/app' ,
426+ {
427+ method : 'POST' ,
428+ headers : { 'Content-Type' : 'application/json' } ,
429+ body : JSON . stringify ( body ) ,
430+ } ,
431+ ) ) ;
432+
433+ it ( 're-evaluates replayed payloads instead of caching an earlier allow' , async ( ) => {
434+ let decisions = 0 ;
435+ mockReadContract . mockImplementation ( ( params ) => {
436+ expect ( params . address ) . toBe ( '0x1234567890123456789012345678901234567890' ) ;
437+ if ( params . functionName === 'isAppAllowed' ) {
438+ decisions += 1 ;
439+ return decisions === 1 ? [ true , 'initial allow' ] : [ false , 'policy changed' ] ;
440+ }
441+ if ( params . functionName === 'gatewayAppId' ) return 'gateway-app' ;
442+ throw new Error ( `unexpected function ${ params . functionName } ` ) ;
443+ } ) ;
444+
445+ const first = await postApp ( ) ;
446+ const replay = await postApp ( ) ;
447+
448+ expect ( await first . json ( ) ) . toMatchObject ( { isAllowed : true , reason : 'initial allow' } ) ;
449+ expect ( await replay . json ( ) ) . toMatchObject ( { isAllowed : false , reason : 'policy changed' } ) ;
450+ expect ( decisions ) . toBe ( 2 ) ;
451+ } ) ;
452+
453+ it ( 'binds changed measurements and identities into distinct contract arguments' , async ( ) => {
454+ const calls : unknown [ ] = [ ] ;
455+ mockReadContract . mockImplementation ( ( params ) => {
456+ if ( params . functionName === 'isAppAllowed' ) {
457+ calls . push ( params . args [ 0 ] ) ;
458+ return [ true , 'allowed' ] ;
459+ }
460+ if ( params . functionName === 'gatewayAppId' ) return 'gateway-app' ;
461+ throw new Error ( `unexpected function ${ params . functionName } ` ) ;
462+ } ) ;
463+
464+ await postApp ( ) ;
465+ await postApp ( { ...requestBody , composeHash : '0x' + '77' . repeat ( 32 ) } ) ;
466+ await postApp ( { ...requestBody , appId : '0x' + '88' . repeat ( 20 ) } ) ;
467+
468+ expect ( calls ) . toHaveLength ( 3 ) ;
469+ expect ( calls [ 0 ] ) . not . toEqual ( calls [ 1 ] ) ;
470+ expect ( calls [ 0 ] ) . not . toEqual ( calls [ 2 ] ) ;
471+ } ) ;
472+
473+ it ( 'fails closed during backend interruption and succeeds after recovery' , async ( ) => {
474+ mockReadContract . mockRejectedValueOnce ( new Error ( 'backend unavailable' ) ) ;
475+ const interrupted = await postApp ( ) ;
476+ expect ( await interrupted . json ( ) ) . toEqual ( {
477+ isAllowed : false ,
478+ gatewayAppId : '' ,
479+ reason : 'authorization backend unavailable' ,
480+ } ) ;
481+
482+ mockReadContract . mockImplementation ( ( params ) => {
483+ if ( params . functionName === 'isAppAllowed' ) return [ true , 'recovered' ] ;
484+ if ( params . functionName === 'gatewayAppId' ) return 'gateway-app' ;
485+ throw new Error ( `unexpected function ${ params . functionName } ` ) ;
486+ } ) ;
487+ const recovered = await postApp ( ) ;
488+ expect ( await recovered . json ( ) ) . toMatchObject ( { isAllowed : true , reason : 'recovered' } ) ;
489+ } ) ;
490+ } ) ;
491+
492+
493+ describe ( 'Ethereum finalized snapshot authorization' , ( ) => {
494+ const requestBody = {
495+ mrAggregated : '0x' + '11' . repeat ( 32 ) ,
496+ osImageHash : '0x' + '22' . repeat ( 32 ) ,
497+ appId : '0x' + '33' . repeat ( 20 ) ,
498+ composeHash : '0x' + '44' . repeat ( 32 ) ,
499+ instanceId : '0x' + '55' . repeat ( 20 ) ,
500+ deviceId : '0x' + '66' . repeat ( 32 ) ,
501+ } ;
502+
503+ const authorize = ( ) => appFetch ( new Request ( 'http://localhost:3001/bootAuth/app' , {
504+ method : 'POST' ,
505+ headers : { 'Content-Type' : 'application/json' } ,
506+ body : JSON . stringify ( requestBody ) ,
507+ } ) ) ;
508+
509+ it ( 'reads the decision and gateway identity from one confirmation-depth snapshot' , async ( ) => {
510+ mockGetBlockNumber . mockResolvedValue ( 100n ) ;
511+ mockReadContract . mockImplementation ( ( params ) => {
512+ expect ( params . blockNumber ) . toBe ( 98n ) ;
513+ if ( params . functionName === 'isAppAllowed' ) return [ true , 'finalized allow' ] ;
514+ if ( params . functionName === 'gatewayAppId' ) return 'gateway-app' ;
515+ throw new Error ( `unexpected function ${ params . functionName } ` ) ;
516+ } ) ;
517+
518+ const response = await authorize ( ) ;
519+ expect ( await response . json ( ) ) . toMatchObject ( { isAllowed : true , reason : 'finalized allow' } ) ;
520+ expect ( mockGetBlockNumber ) . toHaveBeenCalledTimes ( 1 ) ;
521+ } ) ;
522+
523+ it ( 're-evaluates the canonical finalized snapshot after a short reorg' , async ( ) => {
524+ mockGetBlockNumber . mockResolvedValueOnce ( 100n ) . mockResolvedValueOnce ( 101n ) ;
525+ let decisions = 0 ;
526+ const observedBlocks : bigint [ ] = [ ] ;
527+ mockReadContract . mockImplementation ( ( params ) => {
528+ if ( params . functionName === 'isAppAllowed' ) {
529+ observedBlocks . push ( params . blockNumber ) ;
530+ decisions += 1 ;
531+ return decisions === 1 ? [ true , 'old canonical allow' ] : [ false , 'new canonical deny' ] ;
532+ }
533+ if ( params . functionName === 'gatewayAppId' ) return 'gateway-app' ;
534+ throw new Error ( `unexpected function ${ params . functionName } ` ) ;
535+ } ) ;
536+
537+ const before = await authorize ( ) ;
538+ const after = await authorize ( ) ;
539+ expect ( await before . json ( ) ) . toMatchObject ( { isAllowed : true } ) ;
540+ expect ( await after . json ( ) ) . toMatchObject ( { isAllowed : false , reason : 'new canonical deny' } ) ;
541+ expect ( observedBlocks ) . toEqual ( [ 98n , 99n ] ) ;
542+ } ) ;
543+
544+ it . each ( [
545+ [ 'wrong chain' , ( ) => mockGetChainId . mockResolvedValue ( 1 ) ] ,
546+ [ 'stale head' , ( ) => mockGetBlockNumber . mockResolvedValue ( 1n ) ] ,
547+ [ 'head timeout' , ( ) => mockGetBlockNumber . mockRejectedValue ( new Error ( 'timeout' ) ) ] ,
548+ ] ) ( 'fails closed for %s and recovers without retained decisions' , async ( _name , inject ) => {
549+ inject ( ) ;
550+ const failed = await authorize ( ) ;
551+ expect ( await failed . json ( ) ) . toEqual ( {
552+ isAllowed : false ,
553+ gatewayAppId : '' ,
554+ reason : 'authorization backend unavailable' ,
555+ } ) ;
556+
557+ mockGetChainId . mockResolvedValue ( 1337 ) ;
558+ mockGetBlockNumber . mockResolvedValue ( 102n ) ;
559+ mockReadContract . mockImplementation ( ( params ) => {
560+ if ( params . functionName === 'isAppAllowed' ) return [ true , 'recovered' ] ;
561+ if ( params . functionName === 'gatewayAppId' ) return 'gateway-app' ;
562+ throw new Error ( `unexpected function ${ params . functionName } ` ) ;
563+ } ) ;
564+ const recovered = await authorize ( ) ;
565+ expect ( await recovered . json ( ) ) . toMatchObject ( { isAllowed : true , reason : 'recovered' } ) ;
566+ } ) ;
567+ } ) ;
0 commit comments