···11711171 return profile, nil
11721172}
1173117311741174+// StoreProfile writes a profile to both in-memory and persistent caches and
11751175+// maintains the did_by_handle index. Use this when you've already fetched a
11761176+// profile (backfill workers, tests, externally-provided data) and want to seed
11771177+// the cache without going through the public API.
11781178+func (idx *FeedIndex) StoreProfile(ctx context.Context, did string, profile *atproto.Profile) {
11791179+ idx.storeProfile(ctx, did, profile)
11801180+}
11811181+11741182// storeProfile writes a profile to both in-memory and persistent caches, and
11751183// maintains the did_by_handle index so handle lookups stay accurate across
11761184// handle changes and handle reassignment between DIDs.
+221
tests/integration/handle_resolution_test.go
···11+package integration
22+33+import (
44+ "context"
55+ "testing"
66+ "time"
77+88+ "tangled.org/arabica.social/arabica/internal/atproto"
99+ "tangled.org/arabica.social/arabica/internal/firehose"
1010+1111+ "github.com/stretchr/testify/assert"
1212+ "github.com/stretchr/testify/require"
1313+)
1414+1515+// These tests cover the bug where two accounts share a handle (because the
1616+// first account's PDS went away and the user re-registered with a new DID)
1717+// and the witness cache could resolve the handle to the orphan DID. The fix
1818+// keys handle resolution off a `did_by_handle` table with last-writer-wins
1919+// semantics, plus identity-event-driven invalidation of stale prior owners.
2020+2121+const sharedHandle = "shared.test"
2222+2323+// seedProfile writes a profile cache entry for did/handle without going
2424+// through the public API. Mirrors what the profile watcher would do after
2525+// observing a profile commit on the firehose.
2626+func seedProfile(t *testing.T, h *Harness, did, handle string) {
2727+ t.Helper()
2828+ h.FeedIndex.StoreProfile(context.Background(), did, &atproto.Profile{
2929+ DID: did,
3030+ Handle: handle,
3131+ })
3232+}
3333+3434+// TestHandleReassignment_NewOwnerWinsLookup is the direct repro of the bug.
3535+// Two DIDs claim the same handle in sequence; the lookup must return the
3636+// most-recently-written owner.
3737+func TestHandleReassignment_NewOwnerWinsLookup(t *testing.T) {
3838+ h := StartHarness(t, nil)
3939+ ctx := context.Background()
4040+4141+ const orphanDID = "did:plc:orphanaccount"
4242+ const newDID = "did:plc:freshaccount"
4343+4444+ seedProfile(t, h, orphanDID, sharedHandle)
4545+4646+ got, ok := h.FeedIndex.GetDIDByHandle(ctx, sharedHandle)
4747+ require.True(t, ok, "lookup should find the seeded profile")
4848+ assert.Equal(t, orphanDID, got)
4949+5050+ // The new account claims the same handle. After its profile is observed,
5151+ // the handle must resolve to the new DID, not the orphan.
5252+ seedProfile(t, h, newDID, sharedHandle)
5353+5454+ got, ok = h.FeedIndex.GetDIDByHandle(ctx, sharedHandle)
5555+ require.True(t, ok)
5656+ assert.Equal(t, newDID, got, "handle should resolve to the most recent owner, not the orphan")
5757+}
5858+5959+// TestHandleChange_OldHandleStopsResolving covers a single DID changing its
6060+// handle (no reassignment between DIDs). The stale handle row must be cleared
6161+// so the old handle no longer resolves.
6262+func TestHandleChange_OldHandleStopsResolving(t *testing.T) {
6363+ h := StartHarness(t, nil)
6464+ ctx := context.Background()
6565+6666+ const did = "did:plc:movinghandles"
6767+6868+ seedProfile(t, h, did, "old.test")
6969+7070+ got, ok := h.FeedIndex.GetDIDByHandle(ctx, "old.test")
7171+ require.True(t, ok)
7272+ assert.Equal(t, did, got)
7373+7474+ seedProfile(t, h, did, "new.test")
7575+7676+ _, ok = h.FeedIndex.GetDIDByHandle(ctx, "old.test")
7777+ assert.False(t, ok, "old handle should no longer resolve after the DID changes its handle")
7878+7979+ got, ok = h.FeedIndex.GetDIDByHandle(ctx, "new.test")
8080+ require.True(t, ok)
8181+ assert.Equal(t, did, got)
8282+}
8383+8484+// TestPurgeDID_FreesHandleForReuse reproduces the cleanup path: an orphan DID
8585+// is purged, then a new account claiming the same handle resolves cleanly.
8686+// The admin /_mod/purge endpoint isn't reachable from the harness (no
8787+// moderation service), so we exercise the same DeleteAllByDID code path it
8888+// invokes and assert the same downstream invariants.
8989+func TestPurgeDID_FreesHandleForReuse(t *testing.T) {
9090+ h := StartHarness(t, nil)
9191+ ctx := context.Background()
9292+9393+ const orphanDID = "did:plc:orphandead"
9494+ const newDID = "did:plc:newowner"
9595+9696+ seedProfile(t, h, orphanDID, sharedHandle)
9797+9898+ // The orphan also has indexed records — purge must remove them.
9999+ const collection = atproto.NSIDRoaster
100100+ const rkey = "orphanrkey"
101101+ now := time.Now().Unix()
102102+ require.NoError(t, h.FeedIndex.UpsertRecord(
103103+ ctx, orphanDID, collection, rkey, "ciddead",
104104+ []byte(`{"$type":"social.arabica.alpha.roaster","name":"Orphan Roaster","createdAt":"2025-01-01T00:00:00Z"}`),
105105+ now,
106106+ ))
107107+108108+ uri := atproto.BuildATURI(orphanDID, collection, rkey)
109109+ rec, err := h.FeedIndex.GetRecord(ctx, uri)
110110+ require.NoError(t, err)
111111+ require.NotNil(t, rec, "orphan record should be indexed before purge")
112112+113113+ require.NoError(t, h.FeedIndex.DeleteAllByDID(ctx, orphanDID))
114114+ h.FeedIndex.InvalidatePublicCachesForDID(orphanDID)
115115+116116+ rec, err = h.FeedIndex.GetRecord(ctx, uri)
117117+ assert.NoError(t, err)
118118+ assert.Nil(t, rec, "orphan record should be gone after purge")
119119+120120+ _, ok := h.FeedIndex.GetDIDByHandle(ctx, sharedHandle)
121121+ assert.False(t, ok, "purge should remove the handle index entry")
122122+123123+ // New account claims the freed handle.
124124+ seedProfile(t, h, newDID, sharedHandle)
125125+ got, ok := h.FeedIndex.GetDIDByHandle(ctx, sharedHandle)
126126+ require.True(t, ok)
127127+ assert.Equal(t, newDID, got, "freed handle should resolve to the new owner")
128128+}
129129+130130+// TestIdentityEvent_EvictsPriorOwnerOfHandle drives the OnIdentityEvent
131131+// reconciliation path through the profile watcher. When a Jetstream identity
132132+// event reports that a new DID has claimed an existing handle, the prior
133133+// owner's profile cache and handle mapping must be invalidated immediately —
134134+// before the eventual API refresh happens — so a concurrent lookup of the
135135+// shared handle never returns the stale owner.
136136+func TestIdentityEvent_EvictsPriorOwnerOfHandle(t *testing.T) {
137137+ h := StartHarness(t, &HarnessOptions{EnableFirehose: true})
138138+ ctx := context.Background()
139139+140140+ const orphanDID = "did:plc:identityorphan"
141141+ const newDID = "did:plc:identitynew"
142142+143143+ seedProfile(t, h, orphanDID, sharedHandle)
144144+ got, ok := h.FeedIndex.GetDIDByHandle(ctx, sharedHandle)
145145+ require.True(t, ok)
146146+ require.Equal(t, orphanDID, got, "precondition: orphan owns the handle")
147147+148148+ // Synthesize an identity event for the new DID claiming the shared handle.
149149+ // OnIdentityEvent runs prior-owner eviction synchronously; the subsequent
150150+ // RefreshProfile call hits the public bsky API and fails for our synthetic
151151+ // DID, which is fine — we're verifying the eviction half of the path.
152152+ require.NotNil(t, h.ProfileWatcher)
153153+ h.ProfileWatcher.ProcessEvent(firehose.JetstreamEvent{
154154+ DID: newDID,
155155+ TimeUS: time.Now().UnixMicro(),
156156+ Kind: "identity",
157157+ Identity: &firehose.JetstreamIdentity{
158158+ DID: newDID,
159159+ Handle: sharedHandle,
160160+ Time: time.Now().Format(time.RFC3339),
161161+ },
162162+ })
163163+164164+ // The orphan must no longer be the owner of the shared handle. Either the
165165+ // lookup misses entirely (because RefreshProfile failed and InvalidateProfile
166166+ // cleared everything) or it resolves to newDID — both are acceptable; what
167167+ // must not happen is the stale orphan winning.
168168+ got, ok = h.FeedIndex.GetDIDByHandle(ctx, sharedHandle)
169169+ if ok {
170170+ assert.NotEqual(t, orphanDID, got, "shared handle must not resolve to the evicted prior owner")
171171+ }
172172+173173+ // Now seed the new DID's profile (as the watcher would, on a working API).
174174+ // The handle must resolve to newDID, never orphanDID.
175175+ seedProfile(t, h, newDID, sharedHandle)
176176+ got, ok = h.FeedIndex.GetDIDByHandle(ctx, sharedHandle)
177177+ require.True(t, ok)
178178+ assert.Equal(t, newDID, got, "after reconciliation, shared handle resolves to the new owner")
179179+}
180180+181181+// TestHandleResolution_BackfillFromExistingProfiles verifies that a FeedIndex
182182+// opened against a database with pre-existing profile rows but no
183183+// did_by_handle table populates the index on startup, so handle lookups work
184184+// for users observed before this fix shipped.
185185+func TestHandleResolution_BackfillFromExistingProfiles(t *testing.T) {
186186+ tmpDir := t.TempDir()
187187+ dbPath := tmpDir + "/feed-backfill.db"
188188+189189+ // First open: seed two profiles, then close.
190190+ idx, err := firehose.NewFeedIndex(dbPath, time.Hour)
191191+ require.NoError(t, err)
192192+ idx.StoreProfile(context.Background(), "did:plc:backfilla", &atproto.Profile{
193193+ DID: "did:plc:backfilla", Handle: "alice.bf",
194194+ })
195195+ idx.StoreProfile(context.Background(), "did:plc:backfillb", &atproto.Profile{
196196+ DID: "did:plc:backfillb", Handle: "bob.bf",
197197+ })
198198+ require.NoError(t, idx.Close())
199199+200200+ // Drop did_by_handle to simulate a database that predates the index.
201201+ idx2, err := firehose.NewFeedIndex(dbPath, time.Hour)
202202+ require.NoError(t, err)
203203+ defer idx2.Close()
204204+205205+ _, err = idx2.DB().Exec(`DELETE FROM did_by_handle`)
206206+ require.NoError(t, err)
207207+ require.NoError(t, idx2.Close())
208208+209209+ // Reopen — backfill should re-populate the table from the profile rows.
210210+ idx3, err := firehose.NewFeedIndex(dbPath, time.Hour)
211211+ require.NoError(t, err)
212212+ defer idx3.Close()
213213+214214+ got, ok := idx3.GetDIDByHandle(context.Background(), "alice.bf")
215215+ require.True(t, ok, "backfill should restore alice's handle mapping")
216216+ assert.Equal(t, "did:plc:backfilla", got)
217217+218218+ got, ok = idx3.GetDIDByHandle(context.Background(), "bob.bf")
219219+ require.True(t, ok, "backfill should restore bob's handle mapping")
220220+ assert.Equal(t, "did:plc:backfillb", got)
221221+}