@@ -2722,3 +2722,113 @@ func TestRevocationDeletedRole(t *testing.T) {
27222722 assert .Equal (t , "doc" , changes .Results [0 ].ID )
27232723 assert .True (t , changes .Results [0 ].Revoked )
27242724}
2725+
2726+ func TestRevokedFeedPanicLosesRevocations (t * testing.T ) {
2727+ defer db .SuspendSequenceBatching ()()
2728+
2729+ const triggerDocID = "doc-ch105"
2730+ var (
2731+ armed atomic.Bool
2732+ panicFired atomic.Int32
2733+ )
2734+
2735+ leakyConfig := base.LeakyBucketConfig {
2736+ GetWithXattrCallback : func (key string ) error {
2737+ if ! armed .Load () {
2738+ return nil
2739+ }
2740+ if key != triggerDocID {
2741+ return nil
2742+ }
2743+ // Fire exactly once so the recover catches a single panic and we don't
2744+ // loop on retries triggered by the changes feed.
2745+ if panicFired .Add (1 ) != 1 {
2746+ return nil
2747+ }
2748+ panic ("injected revoked-feed panic for " + key )
2749+ },
2750+ }
2751+
2752+ revocationTester , rt := InitScenario (t , & RestTesterConfig {
2753+ LeakyBucketConfig : & leakyConfig ,
2754+ })
2755+ defer rt .Close ()
2756+
2757+ // foo grants both ch1 and ch2, so removing foo later revokes every doc.
2758+ revocationTester .addRole ("user" , "foo" )
2759+ revocationTester .addRoleChannel ("foo" , "ch1" )
2760+ revocationTester .addRoleChannel ("foo" , "ch2" )
2761+
2762+ const numDocs = 10
2763+ for i := 0 ; i < numDocs ; i ++ {
2764+ _ = rt .PutDoc (fmt .Sprintf ("doc-ch1%02d" , i ), `{"channels": ["ch1"]}` )
2765+ }
2766+
2767+ for i := 0 ; i < numDocs ; i ++ {
2768+ _ = rt .PutDoc (fmt .Sprintf ("doc-ch2%02d" , i ), `{"channels": ["ch2"]}` )
2769+ }
2770+
2771+ // User catches up: user doc + all 2*numDocs docs visible as normal changes.
2772+ initial := revocationTester .getChanges ("0" , 2 * numDocs + 1 )
2773+ sinceVal := initial .Last_Seq .String ()
2774+
2775+ // Revoke the role -> ch1 and ch2 access is lost -> revocations for all
2776+ // 2*numDocs docs are pending.
2777+ revocationTester .removeRole ("user" , "foo" )
2778+ rt .WaitForPendingChanges ()
2779+
2780+ // Arm the panic AFTER the test setup so that the bucket reads done during
2781+ // PutDoc/_changes during setup don't trip the trigger.
2782+ armed .Store (true )
2783+ defer armed .Store (false )
2784+
2785+ panickedChanges := rt .GetChanges (
2786+ fmt .Sprintf ("/{{.keyspace}}/_changes?since=%s&revocations=true" , sinceVal ),
2787+ "user" ,
2788+ )
2789+
2790+ require .Equal (t , int32 (1 ), panicFired .Load (),
2791+ "the leaky bucket callback must have actually panicked during the changes request" )
2792+
2793+ // The panic fires partway through the ch1 revocations, so the panicked
2794+ // response is necessarily incomplete: it cannot contain all 2*numDocs
2795+ // revocations.
2796+ revokedSeen := map [string ]bool {}
2797+ for _ , e := range panickedChanges .Results {
2798+ if e .Revoked {
2799+ revokedSeen [e .ID ] = true
2800+ }
2801+ }
2802+ require .Less (t , len (revokedSeen ), 2 * numDocs ,
2803+ "test invariant: panic should have suppressed at least one revocation" )
2804+
2805+ // Follow-up request from the advanced last_seq must recover every revocation
2806+ // the panic suppressed. Before the fix, last_seq moved past the suppressed
2807+ // revocations and they were lost forever.
2808+ followup := rt .GetChanges (
2809+ fmt .Sprintf ("/{{.keyspace}}/_changes?since=%s&revocations=true" ,
2810+ panickedChanges .Last_Seq .String ()),
2811+ "user" ,
2812+ )
2813+ for _ , e := range followup .Results {
2814+ if e .Revoked {
2815+ revokedSeen [e .ID ] = true
2816+ }
2817+ }
2818+
2819+ // Every doc (ch1 and ch2) must eventually be seen as revoked across the
2820+ // panicked response plus the follow-up.
2821+ var missing []string
2822+ for i := 0 ; i < numDocs ; i ++ {
2823+ for _ , prefix := range []string {"doc-ch1" , "doc-ch2" } {
2824+ id := fmt .Sprintf ("%s%02d" , prefix , i )
2825+ if ! revokedSeen [id ] {
2826+ missing = append (missing , id )
2827+ }
2828+ }
2829+ }
2830+ assert .Empty (t , missing ,
2831+ "revocations were permanently lost after panic recovery: %v" , missing )
2832+ assert .Len (t , revokedSeen , 2 * numDocs ,
2833+ "expected every doc to be revoked exactly once across the panicked response and follow-up" )
2834+ }
0 commit comments