Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: integration tests for handle cache changes #20

open opened by pdewey.com targeting main from push-rkwvvmrkprrl
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:hm5f3dnm6jdhrc55qp2npdja/sh.tangled.repo.pull/3mlc7tacyvq22
+229
Diff #0
+8
internal/firehose/index.go
··· 1171 1171 return profile, nil 1172 1172 } 1173 1173 1174 + // StoreProfile writes a profile to both in-memory and persistent caches and 1175 + // maintains the did_by_handle index. Use this when you've already fetched a 1176 + // profile (backfill workers, tests, externally-provided data) and want to seed 1177 + // the cache without going through the public API. 1178 + func (idx *FeedIndex) StoreProfile(ctx context.Context, did string, profile *atproto.Profile) { 1179 + idx.storeProfile(ctx, did, profile) 1180 + } 1181 + 1174 1182 // storeProfile writes a profile to both in-memory and persistent caches, and 1175 1183 // maintains the did_by_handle index so handle lookups stay accurate across 1176 1184 // handle changes and handle reassignment between DIDs.
+221
tests/integration/handle_resolution_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + "tangled.org/arabica.social/arabica/internal/atproto" 9 + "tangled.org/arabica.social/arabica/internal/firehose" 10 + 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 13 + ) 14 + 15 + // These tests cover the bug where two accounts share a handle (because the 16 + // first account's PDS went away and the user re-registered with a new DID) 17 + // and the witness cache could resolve the handle to the orphan DID. The fix 18 + // keys handle resolution off a `did_by_handle` table with last-writer-wins 19 + // semantics, plus identity-event-driven invalidation of stale prior owners. 20 + 21 + const sharedHandle = "shared.test" 22 + 23 + // seedProfile writes a profile cache entry for did/handle without going 24 + // through the public API. Mirrors what the profile watcher would do after 25 + // observing a profile commit on the firehose. 26 + func seedProfile(t *testing.T, h *Harness, did, handle string) { 27 + t.Helper() 28 + h.FeedIndex.StoreProfile(context.Background(), did, &atproto.Profile{ 29 + DID: did, 30 + Handle: handle, 31 + }) 32 + } 33 + 34 + // TestHandleReassignment_NewOwnerWinsLookup is the direct repro of the bug. 35 + // Two DIDs claim the same handle in sequence; the lookup must return the 36 + // most-recently-written owner. 37 + func TestHandleReassignment_NewOwnerWinsLookup(t *testing.T) { 38 + h := StartHarness(t, nil) 39 + ctx := context.Background() 40 + 41 + const orphanDID = "did:plc:orphanaccount" 42 + const newDID = "did:plc:freshaccount" 43 + 44 + seedProfile(t, h, orphanDID, sharedHandle) 45 + 46 + got, ok := h.FeedIndex.GetDIDByHandle(ctx, sharedHandle) 47 + require.True(t, ok, "lookup should find the seeded profile") 48 + assert.Equal(t, orphanDID, got) 49 + 50 + // The new account claims the same handle. After its profile is observed, 51 + // the handle must resolve to the new DID, not the orphan. 52 + seedProfile(t, h, newDID, sharedHandle) 53 + 54 + got, ok = h.FeedIndex.GetDIDByHandle(ctx, sharedHandle) 55 + require.True(t, ok) 56 + assert.Equal(t, newDID, got, "handle should resolve to the most recent owner, not the orphan") 57 + } 58 + 59 + // TestHandleChange_OldHandleStopsResolving covers a single DID changing its 60 + // handle (no reassignment between DIDs). The stale handle row must be cleared 61 + // so the old handle no longer resolves. 62 + func TestHandleChange_OldHandleStopsResolving(t *testing.T) { 63 + h := StartHarness(t, nil) 64 + ctx := context.Background() 65 + 66 + const did = "did:plc:movinghandles" 67 + 68 + seedProfile(t, h, did, "old.test") 69 + 70 + got, ok := h.FeedIndex.GetDIDByHandle(ctx, "old.test") 71 + require.True(t, ok) 72 + assert.Equal(t, did, got) 73 + 74 + seedProfile(t, h, did, "new.test") 75 + 76 + _, ok = h.FeedIndex.GetDIDByHandle(ctx, "old.test") 77 + assert.False(t, ok, "old handle should no longer resolve after the DID changes its handle") 78 + 79 + got, ok = h.FeedIndex.GetDIDByHandle(ctx, "new.test") 80 + require.True(t, ok) 81 + assert.Equal(t, did, got) 82 + } 83 + 84 + // TestPurgeDID_FreesHandleForReuse reproduces the cleanup path: an orphan DID 85 + // is purged, then a new account claiming the same handle resolves cleanly. 86 + // The admin /_mod/purge endpoint isn't reachable from the harness (no 87 + // moderation service), so we exercise the same DeleteAllByDID code path it 88 + // invokes and assert the same downstream invariants. 89 + func TestPurgeDID_FreesHandleForReuse(t *testing.T) { 90 + h := StartHarness(t, nil) 91 + ctx := context.Background() 92 + 93 + const orphanDID = "did:plc:orphandead" 94 + const newDID = "did:plc:newowner" 95 + 96 + seedProfile(t, h, orphanDID, sharedHandle) 97 + 98 + // The orphan also has indexed records โ€” purge must remove them. 99 + const collection = atproto.NSIDRoaster 100 + const rkey = "orphanrkey" 101 + now := time.Now().Unix() 102 + require.NoError(t, h.FeedIndex.UpsertRecord( 103 + ctx, orphanDID, collection, rkey, "ciddead", 104 + []byte(`{"$type":"social.arabica.alpha.roaster","name":"Orphan Roaster","createdAt":"2025-01-01T00:00:00Z"}`), 105 + now, 106 + )) 107 + 108 + uri := atproto.BuildATURI(orphanDID, collection, rkey) 109 + rec, err := h.FeedIndex.GetRecord(ctx, uri) 110 + require.NoError(t, err) 111 + require.NotNil(t, rec, "orphan record should be indexed before purge") 112 + 113 + require.NoError(t, h.FeedIndex.DeleteAllByDID(ctx, orphanDID)) 114 + h.FeedIndex.InvalidatePublicCachesForDID(orphanDID) 115 + 116 + rec, err = h.FeedIndex.GetRecord(ctx, uri) 117 + assert.NoError(t, err) 118 + assert.Nil(t, rec, "orphan record should be gone after purge") 119 + 120 + _, ok := h.FeedIndex.GetDIDByHandle(ctx, sharedHandle) 121 + assert.False(t, ok, "purge should remove the handle index entry") 122 + 123 + // New account claims the freed handle. 124 + seedProfile(t, h, newDID, sharedHandle) 125 + got, ok := h.FeedIndex.GetDIDByHandle(ctx, sharedHandle) 126 + require.True(t, ok) 127 + assert.Equal(t, newDID, got, "freed handle should resolve to the new owner") 128 + } 129 + 130 + // TestIdentityEvent_EvictsPriorOwnerOfHandle drives the OnIdentityEvent 131 + // reconciliation path through the profile watcher. When a Jetstream identity 132 + // event reports that a new DID has claimed an existing handle, the prior 133 + // owner's profile cache and handle mapping must be invalidated immediately โ€” 134 + // before the eventual API refresh happens โ€” so a concurrent lookup of the 135 + // shared handle never returns the stale owner. 136 + func TestIdentityEvent_EvictsPriorOwnerOfHandle(t *testing.T) { 137 + h := StartHarness(t, &HarnessOptions{EnableFirehose: true}) 138 + ctx := context.Background() 139 + 140 + const orphanDID = "did:plc:identityorphan" 141 + const newDID = "did:plc:identitynew" 142 + 143 + seedProfile(t, h, orphanDID, sharedHandle) 144 + got, ok := h.FeedIndex.GetDIDByHandle(ctx, sharedHandle) 145 + require.True(t, ok) 146 + require.Equal(t, orphanDID, got, "precondition: orphan owns the handle") 147 + 148 + // Synthesize an identity event for the new DID claiming the shared handle. 149 + // OnIdentityEvent runs prior-owner eviction synchronously; the subsequent 150 + // RefreshProfile call hits the public bsky API and fails for our synthetic 151 + // DID, which is fine โ€” we're verifying the eviction half of the path. 152 + require.NotNil(t, h.ProfileWatcher) 153 + h.ProfileWatcher.ProcessEvent(firehose.JetstreamEvent{ 154 + DID: newDID, 155 + TimeUS: time.Now().UnixMicro(), 156 + Kind: "identity", 157 + Identity: &firehose.JetstreamIdentity{ 158 + DID: newDID, 159 + Handle: sharedHandle, 160 + Time: time.Now().Format(time.RFC3339), 161 + }, 162 + }) 163 + 164 + // The orphan must no longer be the owner of the shared handle. Either the 165 + // lookup misses entirely (because RefreshProfile failed and InvalidateProfile 166 + // cleared everything) or it resolves to newDID โ€” both are acceptable; what 167 + // must not happen is the stale orphan winning. 168 + got, ok = h.FeedIndex.GetDIDByHandle(ctx, sharedHandle) 169 + if ok { 170 + assert.NotEqual(t, orphanDID, got, "shared handle must not resolve to the evicted prior owner") 171 + } 172 + 173 + // Now seed the new DID's profile (as the watcher would, on a working API). 174 + // The handle must resolve to newDID, never orphanDID. 175 + seedProfile(t, h, newDID, sharedHandle) 176 + got, ok = h.FeedIndex.GetDIDByHandle(ctx, sharedHandle) 177 + require.True(t, ok) 178 + assert.Equal(t, newDID, got, "after reconciliation, shared handle resolves to the new owner") 179 + } 180 + 181 + // TestHandleResolution_BackfillFromExistingProfiles verifies that a FeedIndex 182 + // opened against a database with pre-existing profile rows but no 183 + // did_by_handle table populates the index on startup, so handle lookups work 184 + // for users observed before this fix shipped. 185 + func TestHandleResolution_BackfillFromExistingProfiles(t *testing.T) { 186 + tmpDir := t.TempDir() 187 + dbPath := tmpDir + "/feed-backfill.db" 188 + 189 + // First open: seed two profiles, then close. 190 + idx, err := firehose.NewFeedIndex(dbPath, time.Hour) 191 + require.NoError(t, err) 192 + idx.StoreProfile(context.Background(), "did:plc:backfilla", &atproto.Profile{ 193 + DID: "did:plc:backfilla", Handle: "alice.bf", 194 + }) 195 + idx.StoreProfile(context.Background(), "did:plc:backfillb", &atproto.Profile{ 196 + DID: "did:plc:backfillb", Handle: "bob.bf", 197 + }) 198 + require.NoError(t, idx.Close()) 199 + 200 + // Drop did_by_handle to simulate a database that predates the index. 201 + idx2, err := firehose.NewFeedIndex(dbPath, time.Hour) 202 + require.NoError(t, err) 203 + defer idx2.Close() 204 + 205 + _, err = idx2.DB().Exec(`DELETE FROM did_by_handle`) 206 + require.NoError(t, err) 207 + require.NoError(t, idx2.Close()) 208 + 209 + // Reopen โ€” backfill should re-populate the table from the profile rows. 210 + idx3, err := firehose.NewFeedIndex(dbPath, time.Hour) 211 + require.NoError(t, err) 212 + defer idx3.Close() 213 + 214 + got, ok := idx3.GetDIDByHandle(context.Background(), "alice.bf") 215 + require.True(t, ok, "backfill should restore alice's handle mapping") 216 + assert.Equal(t, "did:plc:backfilla", got) 217 + 218 + got, ok = idx3.GetDIDByHandle(context.Background(), "bob.bf") 219 + require.True(t, ok, "backfill should restore bob's handle mapping") 220 + assert.Equal(t, "did:plc:backfillb", got) 221 + }

History

1 round 0 comments
sign up or login to add to the discussion
pdewey.com submitted #0
1 commit
expand
test: integration tests for handle cache changes
merge conflicts detected
expand
  • internal/firehose/index.go:128
  • internal/firehose/profile_watcher.go:308
  • internal/handlers/admin.go:791
  • internal/routing/routing.go:198
expand 0 comments