@@ -2,8 +2,12 @@ package services
22
33import (
44 "context"
5+ "fmt"
6+ "sync"
7+ "sync/atomic"
58 "testing"
69
10+ "github.com/descope/authzcache/internal/config"
711 "github.com/descope/authzcache/internal/services/caches"
812 "github.com/descope/go-sdk/descope"
913 "github.com/descope/go-sdk/descope/logger"
@@ -531,3 +535,122 @@ func TestWhatCanTargetAccess_IndirectRelationRemoved_FilteredImmediately(t *test
531535 require .Len (t , result , 1 )
532536 require .Equal (t , "doc1" , result [0 ].Resource )
533537}
538+
539+ func TestCheckAsyncCacheUpdate (t * testing.T ) {
540+ t .Setenv (config .ConfigKeyAsyncCacheUpdate , "true" )
541+ // setup mocks
542+ ac , mockSDK , mockCache := injectAuthzMocks (t )
543+ relations := []* descope.FGARelation {{Resource : "mario" , Target : "luigi" , Relation : "bigBro" }}
544+ sdkChecks := []* descope.FGACheck {{Allowed : true , Relation : relations [0 ], Info : & descope.FGACheckInfo {Direct : true }}}
545+ mockSDK .MockFGA .CheckResponse = sdkChecks
546+ mockCache .CheckRelationFunc = func (_ context.Context , _ * descope.FGARelation ) (bool , bool , bool ) {
547+ return false , false , false
548+ }
549+ // use a WaitGroup to detect when the async goroutine calls UpdateCacheWithChecks
550+ var wg sync.WaitGroup
551+ wg .Add (1 )
552+ mockCache .UpdateCacheWithChecksFunc = func (_ context.Context , checks []* descope.FGACheck ) {
553+ defer wg .Done ()
554+ require .Equal (t , sdkChecks , checks )
555+ }
556+ // run test
557+ result , err := ac .Check (context .TODO (), relations )
558+ require .NoError (t , err )
559+ require .Equal (t , sdkChecks , result )
560+ // wait for the async cache update to complete
561+ wg .Wait ()
562+ }
563+
564+ func TestCheckSyncCacheUpdate (t * testing.T ) {
565+ t .Setenv (config .ConfigKeyAsyncCacheUpdate , "false" )
566+ // setup mocks
567+ ac , mockSDK , mockCache := injectAuthzMocks (t )
568+ relations := []* descope.FGARelation {{Resource : "mario" , Target : "luigi" , Relation : "bigBro" }}
569+ sdkChecks := []* descope.FGACheck {{Allowed : true , Relation : relations [0 ], Info : & descope.FGACheckInfo {Direct : true }}}
570+ mockSDK .MockFGA .CheckResponse = sdkChecks
571+ mockCache .CheckRelationFunc = func (_ context.Context , _ * descope.FGARelation ) (bool , bool , bool ) {
572+ return false , false , false
573+ }
574+ // track that UpdateCacheWithChecks is called before Check returns
575+ cacheUpdated := false
576+ mockCache .UpdateCacheWithChecksFunc = func (_ context.Context , checks []* descope.FGACheck ) {
577+ require .Equal (t , sdkChecks , checks )
578+ cacheUpdated = true
579+ }
580+ // run test
581+ result , err := ac .Check (context .TODO (), relations )
582+ require .NoError (t , err )
583+ require .Equal (t , sdkChecks , result )
584+ // in sync mode, cache must already be updated when Check returns
585+ require .True (t , cacheUpdated , "UpdateCacheWithChecks should be called synchronously before Check returns" )
586+ }
587+
588+ func BenchmarkCheck (b * testing.B ) {
589+ for _ , numRelations := range []int {500 , 1000 , 5000 } {
590+ for _ , async := range []bool {false , true } {
591+ name := fmt .Sprintf ("relations=%d/async=%v" , numRelations , async )
592+ b .Run (name , func (b * testing.B ) {
593+ if async {
594+ b .Setenv (config .ConfigKeyAsyncCacheUpdate , "true" )
595+ } else {
596+ b .Setenv (config .ConfigKeyAsyncCacheUpdate , "false" )
597+ }
598+ // setup: real cache + mock SDK returning instant results
599+ ctx := context .TODO ()
600+ projectCache , err := caches .NewProjectAuthzCache (ctx , nil )
601+ if err != nil {
602+ b .Fatal (err )
603+ }
604+ // fixed SDK response — same length as input, reused across goroutines (read-only)
605+ sdkResponse := make ([]* descope.FGACheck , numRelations )
606+ for i := range sdkResponse {
607+ sdkResponse [i ] = & descope.FGACheck {
608+ Allowed : i % 2 == 0 ,
609+ Relation : & descope.FGARelation {Resource : fmt .Sprintf ("sdk-r-%d" , i ), Target : fmt .Sprintf ("sdk-t-%d" , i ), Relation : "viewer" , ResourceType : "doc" },
610+ Info : & descope.FGACheckInfo {Direct : true },
611+ }
612+ }
613+ mockFGA := & mocksmgmt.MockFGA {CheckResponse : sdkResponse }
614+ mockSDK := & mocksmgmt.MockManagement {
615+ MockFGA : mockFGA ,
616+ MockAuthz : & mocksmgmt.MockAuthz {},
617+ }
618+ mockRemoteClientCreator := func (_ string , _ logger.LoggerInterface ) (sdk.Management , error ) {
619+ return mockSDK , nil
620+ }
621+ mockCacheCreator := func (_ context.Context , _ caches.RemoteChangesChecker ) (caches.ProjectAuthzCache , error ) {
622+ return projectCache , nil
623+ }
624+ ac , err := New (ctx , mockCacheCreator , mockRemoteClientCreator )
625+ if err != nil {
626+ b .Fatal (err )
627+ }
628+ // warm up: trigger project cache creation
629+ warmupRel := []* descope.FGARelation {{Resource : "warmup" , Target : "warmup" , Relation : "viewer" , ResourceType : "doc" }}
630+ origResp := mockFGA .CheckResponse
631+ mockFGA .CheckResponse = []* descope.FGACheck {{Allowed : true , Relation : warmupRel [0 ], Info : & descope.FGACheckInfo {Direct : true }}}
632+ _ , _ = ac .Check (ctx , warmupRel )
633+ mockFGA .CheckResponse = origResp
634+
635+ var counter atomic.Int64
636+ b .ResetTimer ()
637+ b .RunParallel (func (pb * testing.PB ) {
638+ for pb .Next () {
639+ id := counter .Add (1 )
640+ // unique relations per call — always cache misses
641+ rels := make ([]* descope.FGARelation , numRelations )
642+ for j := range numRelations {
643+ rels [j ] = & descope.FGARelation {
644+ Resource : fmt .Sprintf ("r-%d-%d" , id , j ),
645+ Target : fmt .Sprintf ("t-%d-%d" , id , j ),
646+ Relation : "viewer" ,
647+ ResourceType : "doc" ,
648+ }
649+ }
650+ _ , _ = ac .Check (ctx , rels )
651+ }
652+ })
653+ })
654+ }
655+ }
656+ }
0 commit comments