+229
Diff
round #0
+8
internal/firehose/index.go
+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
+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
pdewey.com
submitted
#0
1 commit
expand
collapse
test: integration tests for handle cache changes
merge conflicts detected
expand
collapse
expand
collapse
- internal/firehose/index.go:128
- internal/firehose/profile_watcher.go:308
- internal/handlers/admin.go:791
- internal/routing/routing.go:198