@@ -2959,6 +2959,228 @@ func TestInvalidateChannels(t *testing.T) {
29592959 }
29602960}
29612961
2962+ // getRawPrincipal fetches the principal doc directly from the datastore, avoiding the channel
2963+ // rebuild performed by GetUser/GetRole, so invalidation sequences can be inspected.
2964+ func getRawPrincipal (t * testing.T , auth * Authenticator , name string , isUser bool ) Principal {
2965+ ctx := base .TestCtx (t )
2966+ var princ Principal
2967+ var docID string
2968+ if isUser {
2969+ docID = auth .DocIDForUser (name )
2970+ princ = & userImpl {roleImpl : roleImpl {docID : docID }}
2971+ } else {
2972+ docID = auth .DocIDForRole (name )
2973+ princ = & roleImpl {docID : docID }
2974+ }
2975+ cas , err := auth .datastore .Get (ctx , docID , & princ )
2976+ require .NoError (t , err )
2977+ princ .SetCas (cas )
2978+ return princ
2979+ }
2980+
2981+ // createTestPrincipal creates and saves a user or role with a single explicit channel grant.
2982+ func createTestPrincipal (t * testing.T , auth * Authenticator , name string , isUser bool ) {
2983+ var princ Principal
2984+ var err error
2985+ if isUser {
2986+ princ , err = auth .NewUser (name , "password" , ch .BaseSetOf (t , "ABC" ))
2987+ } else {
2988+ princ , err = auth .NewRole (name , ch .BaseSetOf (t , "ABC" ))
2989+ }
2990+ require .NoError (t , err )
2991+ require .NoError (t , auth .Save (princ ))
2992+ }
2993+
2994+ // TestInvalidateChannelsIdempotent verifies that re-invalidating principals at the same or a
2995+ // later sequence preserves the original invalidation sequence and does not write to the store.
2996+ // It covers InvalidateDefaultChannels, InvalidateChannels, InvalidateRolesAndChannels, and
2997+ // UpdateSequenceNumberForResync under a single test bucket.
2998+ func TestInvalidateChannelsIdempotent (t * testing.T ) {
2999+ const (
3000+ scopeName = "scope1"
3001+ collectionName = "collection1"
3002+ )
3003+
3004+ ctx := base .TestCtx (t )
3005+ bucket := base .GetTestBucket (t )
3006+ defer bucket .Close (ctx )
3007+
3008+ collections := base.ScopeAndCollectionNames {
3009+ base .DefaultScopeAndCollectionName (),
3010+ base .NewScopeAndCollectionName (scopeName , collectionName ),
3011+ }
3012+
3013+ t .Run ("InvalidateDefaultChannels" , func (t * testing.T ) {
3014+ auth := NewTestAuthenticator (t , bucket .GetSingleDataStore (), nil , DefaultAuthenticatorOptions (ctx ))
3015+
3016+ testCases := []struct {
3017+ name string
3018+ principalName string
3019+ isUser bool
3020+ }{
3021+ {name : "User" , principalName : "idempotent_default_user" , isUser : true },
3022+ {name : "Role" , principalName : "idempotent_default_role" , isUser : false },
3023+ }
3024+ for _ , testCase := range testCases {
3025+ t .Run (testCase .name , func (t * testing.T ) {
3026+ createTestPrincipal (t , auth , testCase .principalName , testCase .isUser )
3027+
3028+ require .NoError (t , auth .InvalidateDefaultChannels (testCase .principalName , testCase .isUser , 5 ))
3029+ princ := getRawPrincipal (t , auth , testCase .principalName , testCase .isUser )
3030+ require .Equal (t , uint64 (5 ), princ .GetChannelInvalSeq ())
3031+ casAfterFirst := princ .Cas ()
3032+
3033+ // Re-invalidating at the same sequence must not write (CAS unchanged)
3034+ require .NoError (t , auth .InvalidateDefaultChannels (testCase .principalName , testCase .isUser , 5 ))
3035+ princ = getRawPrincipal (t , auth , testCase .principalName , testCase .isUser )
3036+ require .Equal (t , uint64 (5 ), princ .GetChannelInvalSeq ())
3037+ require .Equal (t , casAfterFirst , princ .Cas ())
3038+
3039+ // Re-invalidating at a later sequence must keep the original invalidation sequence and not write
3040+ require .NoError (t , auth .InvalidateDefaultChannels (testCase .principalName , testCase .isUser , 20 ))
3041+ princ = getRawPrincipal (t , auth , testCase .principalName , testCase .isUser )
3042+ require .Equal (t , uint64 (5 ), princ .GetChannelInvalSeq ())
3043+ require .Equal (t , casAfterFirst , princ .Cas ())
3044+ })
3045+ }
3046+ })
3047+
3048+ t .Run ("InvalidateChannels" , func (t * testing.T ) {
3049+ options := DefaultAuthenticatorOptions (ctx )
3050+ options .Collections = map [string ]map [string ]struct {}{
3051+ base .DefaultScope : {base .DefaultCollection : struct {}{}},
3052+ scopeName : {collectionName : struct {}{}},
3053+ }
3054+ auth := NewTestAuthenticator (t , bucket .GetSingleDataStore (), & mockComputer {}, options )
3055+
3056+ testCases := []struct {
3057+ name string
3058+ principalName string
3059+ isUser bool
3060+ }{
3061+ {name : "User" , principalName : "idempotent_channels_user" , isUser : true },
3062+ {name : "Role" , principalName : "idempotent_channels_role" , isUser : false },
3063+ }
3064+ for _ , testCase := range testCases {
3065+ t .Run (testCase .name , func (t * testing.T ) {
3066+ createTestPrincipal (t , auth , testCase .principalName , testCase .isUser )
3067+
3068+ // Invalidating more than one collection forces the update (non-subdoc) pathway
3069+ require .NoError (t , auth .InvalidateChannels (testCase .principalName , testCase .isUser , collections , 5 ))
3070+ princ := getRawPrincipal (t , auth , testCase .principalName , testCase .isUser )
3071+ require .Equal (t , uint64 (5 ), princ .GetChannelInvalSeq ())
3072+ require .Equal (t , uint64 (5 ), princ .getCollectionChannelInvalSeq (scopeName , collectionName ))
3073+ casAfterFirst := princ .Cas ()
3074+
3075+ // Re-invalidating at the same sequence must not write (CAS unchanged)
3076+ require .NoError (t , auth .InvalidateChannels (testCase .principalName , testCase .isUser , collections , 5 ))
3077+ princ = getRawPrincipal (t , auth , testCase .principalName , testCase .isUser )
3078+ require .Equal (t , uint64 (5 ), princ .GetChannelInvalSeq ())
3079+ require .Equal (t , uint64 (5 ), princ .getCollectionChannelInvalSeq (scopeName , collectionName ))
3080+ require .Equal (t , casAfterFirst , princ .Cas ())
3081+
3082+ // Re-invalidating at a later sequence must keep the original invalidation sequence and not write
3083+ require .NoError (t , auth .InvalidateChannels (testCase .principalName , testCase .isUser , collections , 20 ))
3084+ princ = getRawPrincipal (t , auth , testCase .principalName , testCase .isUser )
3085+ require .Equal (t , uint64 (5 ), princ .GetChannelInvalSeq ())
3086+ require .Equal (t , uint64 (5 ), princ .getCollectionChannelInvalSeq (scopeName , collectionName ))
3087+ require .Equal (t , casAfterFirst , princ .Cas ())
3088+ })
3089+ }
3090+ })
3091+
3092+ t .Run ("InvalidateRolesAndChannels" , func (t * testing.T ) {
3093+ computer := mockComputer {roles : ch .AtSequence (ch .BaseSetOf (t , "role1" ), 1 )}
3094+ options := DefaultAuthenticatorOptions (ctx )
3095+ options .Collections = map [string ]map [string ]struct {}{
3096+ base .DefaultScope : {base .DefaultCollection : struct {}{}},
3097+ scopeName : {collectionName : struct {}{}},
3098+ }
3099+ auth := NewTestAuthenticator (t , bucket .GetSingleDataStore (), & computer , options )
3100+
3101+ t .Run ("First invalidation sets sequence" , func (t * testing.T ) {
3102+ createTestPrincipal (t , auth , "invalidate_roles_channels_first" , true )
3103+
3104+ require .NoError (t , auth .InvalidateRolesAndChannels ("invalidate_roles_channels_first" , collections , 5 ))
3105+ user := getRawPrincipal (t , auth , "invalidate_roles_channels_first" , true ).(* userImpl )
3106+ require .Equal (t , uint64 (5 ), user .GetRoleInvalSeq ())
3107+ require .Equal (t , uint64 (5 ), user .GetChannelInvalSeq ())
3108+ require .Equal (t , uint64 (5 ), user .getCollectionChannelInvalSeq (scopeName , collectionName ))
3109+ })
3110+
3111+ t .Run ("Re-invalidation at later sequence preserves original" , func (t * testing.T ) {
3112+ createTestPrincipal (t , auth , "invalidate_roles_channels_reinvalidate" , true )
3113+
3114+ require .NoError (t , auth .InvalidateRolesAndChannels ("invalidate_roles_channels_reinvalidate" , collections , 5 ))
3115+ casAfterFirst := getRawPrincipal (t , auth , "invalidate_roles_channels_reinvalidate" , true ).Cas ()
3116+
3117+ require .NoError (t , auth .InvalidateRolesAndChannels ("invalidate_roles_channels_reinvalidate" , collections , 20 ))
3118+ user := getRawPrincipal (t , auth , "invalidate_roles_channels_reinvalidate" , true ).(* userImpl )
3119+ require .Equal (t , uint64 (5 ), user .GetRoleInvalSeq ())
3120+ require .Equal (t , uint64 (5 ), user .GetChannelInvalSeq ())
3121+ require .Equal (t , uint64 (5 ), user .getCollectionChannelInvalSeq (scopeName , collectionName ))
3122+ require .Equal (t , casAfterFirst , user .Cas ())
3123+ })
3124+ })
3125+
3126+ t .Run ("UpdateSequenceNumberForResync" , func (t * testing.T ) {
3127+ auth := NewTestAuthenticator (t , bucket .GetSingleDataStore (), nil , DefaultAuthenticatorOptions (ctx ))
3128+
3129+ t .Run ("Empty resyncID error" , func (t * testing.T ) {
3130+ user , err := auth .NewUser ("resync_user_empty" , "pass" , nil )
3131+ require .NoError (t , err )
3132+ require .NoError (t , auth .Save (user ))
3133+
3134+ err = auth .UpdateSequenceNumberForResync (user , 10 , "" )
3135+ assert .Error (t , err )
3136+ assert .Contains (t , err .Error (), "resyncID must not be empty" )
3137+ })
3138+
3139+ t .Run ("Matching resyncID (CAS should NOT update)" , func (t * testing.T ) {
3140+ user , err := auth .NewUser ("resync_user_match" , "pass" , nil )
3141+ require .NoError (t , err )
3142+ require .NoError (t , auth .Save (user ))
3143+
3144+ // First call with "resync-1" - updates CAS
3145+ require .NoError (t , auth .UpdateSequenceNumberForResync (user , 10 , "resync-1" ))
3146+
3147+ dbUser , err := auth .GetUser ("resync_user_match" )
3148+ require .NoError (t , err )
3149+ initialCas := dbUser .Cas ()
3150+
3151+ // Second call with "resync-1" - should be skipped, CAS must not update
3152+ require .NoError (t , auth .UpdateSequenceNumberForResync (dbUser , 20 , "resync-1" ))
3153+
3154+ finalUser , err := auth .GetUser ("resync_user_match" )
3155+ require .NoError (t , err )
3156+ assert .Equal (t , initialCas , finalUser .Cas (), "CAS should not have changed" )
3157+ assert .Equal (t , uint64 (10 ), finalUser .Sequence (), "Sequence should remain unchanged" )
3158+ })
3159+
3160+ t .Run ("Mismatching resyncID (CAS should update)" , func (t * testing.T ) {
3161+ user , err := auth .NewUser ("resync_user_mismatch" , "pass" , nil )
3162+ require .NoError (t , err )
3163+ require .NoError (t , auth .Save (user ))
3164+
3165+ // First call with "resync-1"
3166+ require .NoError (t , auth .UpdateSequenceNumberForResync (user , 10 , "resync-1" ))
3167+
3168+ dbUser , err := auth .GetUser ("resync_user_mismatch" )
3169+ require .NoError (t , err )
3170+ casAfterFirst := dbUser .Cas ()
3171+
3172+ // Second call with "resync-2" - mismatch, should update CAS
3173+ require .NoError (t , auth .UpdateSequenceNumberForResync (dbUser , 20 , "resync-2" ))
3174+
3175+ finalUser , err := auth .GetUser ("resync_user_mismatch" )
3176+ require .NoError (t , err )
3177+ assert .NotEqual (t , casAfterFirst , finalUser .Cas (), "CAS must be updated" )
3178+ assert .Equal (t , uint64 (20 ), finalUser .Sequence (), "Sequence should be updated to 20" )
3179+ assert .Equal (t , "resync-2" , finalUser .ResyncID (), "ResyncID should be updated to resync-2" )
3180+ })
3181+ })
3182+ }
3183+
29623184func TestCalculateMaxHistoryEntriesPerGrant (t * testing.T ) {
29633185 testCases := []struct {
29643186 Name string
0 commit comments