@@ -545,3 +545,132 @@ test.serial(`${currentTest} GET public-permissions returns the configured tables
545545 t . truthy ( tableEntry ) ;
546546 t . deepEqual ( tableEntry . readableColumns , [ testTableColumnName ] ) ;
547547} ) ;
548+
549+ currentTest = 'Public access security invariants' ;
550+
551+ test . serial (
552+ `${ currentTest } EVERY CRUD endpoint denies an unauthenticated user when no public policy is configured` ,
553+ async ( t ) => {
554+ const { connectionId, testTableName, testTableColumnName, testTableSecondColumnName } =
555+ await createConnectionAndTable ( ) ;
556+
557+ const row = JSON . stringify ( {
558+ [ testTableColumnName ] : faker . person . firstName ( ) ,
559+ [ testTableSecondColumnName ] : faker . internet . email ( ) ,
560+ } ) ;
561+
562+ // getRows
563+ const getRows = await request ( app . getHttpServer ( ) )
564+ . post ( `/table/crud/rows/${ connectionId } ?tableName=${ testTableName } &page=1&perPage=100` )
565+ . send ( { } )
566+ . set ( 'Content-Type' , 'application/json' )
567+ . set ( 'Accept' , 'application/json' ) ;
568+ t . is ( getRows . status , 403 ) ;
569+ t . is ( Object . hasOwn ( JSON . parse ( getRows . text ) , 'rows' ) , false ) ;
570+
571+ // readRow by primary key
572+ const readRow = await request ( app . getHttpServer ( ) )
573+ . get ( `/table/crud/${ connectionId } ?tableName=${ testTableName } &id=1` )
574+ . set ( 'Content-Type' , 'application/json' )
575+ . set ( 'Accept' , 'application/json' ) ;
576+ t . is ( readRow . status , 403 ) ;
577+ t . is ( Object . hasOwn ( JSON . parse ( readRow . text ) , 'row' ) , false ) ;
578+
579+ // createRow
580+ const createRow = await request ( app . getHttpServer ( ) )
581+ . post ( `/table/crud/${ connectionId } ?tableName=${ testTableName } ` )
582+ . send ( row )
583+ . set ( 'Content-Type' , 'application/json' )
584+ . set ( 'Accept' , 'application/json' ) ;
585+ t . is ( createRow . status , 403 ) ;
586+
587+ // updateRow
588+ const updateRow = await request ( app . getHttpServer ( ) )
589+ . put ( `/table/crud/${ connectionId } ?tableName=${ testTableName } &id=1` )
590+ . send ( row )
591+ . set ( 'Content-Type' , 'application/json' )
592+ . set ( 'Accept' , 'application/json' ) ;
593+ t . is ( updateRow . status , 403 ) ;
594+
595+ // deleteRow
596+ const deleteRow = await request ( app . getHttpServer ( ) )
597+ . delete ( `/table/crud/${ connectionId } ?tableName=${ testTableName } &id=1` )
598+ . set ( 'Content-Type' , 'application/json' )
599+ . set ( 'Accept' , 'application/json' ) ;
600+ t . is ( deleteRow . status , 403 ) ;
601+ } ,
602+ ) ;
603+
604+ test . serial ( `${ currentTest } a public policy scoped to one table does NOT grant access to other tables` , async ( t ) => {
605+ const { token, connectionId, testTableName } = await createConnectionAndTable ( ) ;
606+
607+ // A second table living in the same database (same connection).
608+ const connectionToTestDB = getTestData ( mockFactory ) . connectionToMySQL ;
609+ const secondTable = await createTestTable ( connectionToTestDB ) ;
610+ testTables . push ( secondTable . testTableName ) ;
611+
612+ // Grant public access to the FIRST table only.
613+ const setRes = await setPublicPermissions ( connectionId , token , [ { tableName : testTableName } ] ) ;
614+ t . is ( setRes . status , 200 ) ;
615+
616+ // The granted table is reachable...
617+ const allowed = await request ( app . getHttpServer ( ) )
618+ . post ( `/table/crud/rows/${ connectionId } ?tableName=${ testTableName } &page=1&perPage=10` )
619+ . send ( { } )
620+ . set ( 'Content-Type' , 'application/json' )
621+ . set ( 'Accept' , 'application/json' ) ;
622+ t . is ( allowed . status , 200 ) ;
623+
624+ // ...but the OTHER table must not be: no row data may leak.
625+ const deniedRows = await request ( app . getHttpServer ( ) )
626+ . post ( `/table/crud/rows/${ connectionId } ?tableName=${ secondTable . testTableName } &page=1&perPage=10` )
627+ . send ( { } )
628+ . set ( 'Content-Type' , 'application/json' )
629+ . set ( 'Accept' , 'application/json' ) ;
630+ t . is ( deniedRows . status , 403 ) ;
631+ t . is ( Object . hasOwn ( JSON . parse ( deniedRows . text ) , 'rows' ) , false ) ;
632+
633+ const deniedRead = await request ( app . getHttpServer ( ) )
634+ . get ( `/table/crud/${ connectionId } ?tableName=${ secondTable . testTableName } &id=1` )
635+ . set ( 'Content-Type' , 'application/json' )
636+ . set ( 'Accept' , 'application/json' ) ;
637+ t . is ( deniedRead . status , 403 ) ;
638+ } ) ;
639+
640+ test . serial ( `${ currentTest } readRow strips non-readable columns for an unauthenticated user` , async ( t ) => {
641+ const { token, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } =
642+ await createConnectionAndTable ( ) ;
643+
644+ await setPublicPermissions ( connectionId , token , [
645+ { tableName : testTableName , readableColumns : [ testTableColumnName ] } ,
646+ ] ) ;
647+
648+ const res = await request ( app . getHttpServer ( ) )
649+ . get ( `/table/crud/${ connectionId } ?tableName=${ testTableName } &id=1` )
650+ . set ( 'Content-Type' , 'application/json' )
651+ . set ( 'Accept' , 'application/json' ) ;
652+
653+ t . is ( res . status , 200 ) ;
654+ const ro = JSON . parse ( res . text ) ;
655+ t . is ( Object . hasOwn ( ro . row , testTableColumnName ) , true ) ;
656+ t . is ( Object . hasOwn ( ro . row , testTableSecondColumnName ) , false ) ;
657+ t . is ( Object . hasOwn ( ro . row , 'id' ) , false ) ;
658+ } ) ;
659+
660+ test . serial ( `${ currentTest } an invalid/expired JWT cookie is rejected, never treated as public` , async ( t ) => {
661+ const { connectionId, token, testTableName } = await createConnectionAndTable ( ) ;
662+ // Enable public access so that, if the bad token were silently ignored, the request would succeed.
663+ await setPublicPermissions ( connectionId , token , [ { tableName : testTableName } ] ) ;
664+
665+ const res = await request ( app . getHttpServer ( ) )
666+ . post ( `/table/crud/rows/${ connectionId } ?tableName=${ testTableName } &page=1&perPage=10` )
667+ . send ( { } )
668+ . set ( 'Cookie' , [ 'rocketadmin_cookie=this.is.not.a.valid.jwt' ] )
669+ . set ( 'Content-Type' , 'application/json' )
670+ . set ( 'Accept' , 'application/json' ) ;
671+
672+ // A malformed credential must be rejected, never silently downgraded to public access.
673+ // (The existing middleware surfaces this as a 4xx/5xx; the invariant is "not 200, no data leaked".)
674+ t . not ( res . status , 200 ) ;
675+ t . is ( Object . hasOwn ( JSON . parse ( res . text ) , 'rows' ) , false ) ;
676+ } ) ;
0 commit comments