Monorepo for Tangled
tangled.org
1package keys
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "path/filepath"
10 "testing"
11
12 comatproto "github.com/bluesky-social/indigo/api/atproto"
13 "github.com/bluesky-social/indigo/atproto/identity"
14 "github.com/bluesky-social/indigo/atproto/syntax"
15 lexutil "github.com/bluesky-social/indigo/lex/util"
16 "tangled.org/core/api/tangled"
17 "tangled.org/core/knotserver/db"
18)
19
20const (
21 didBoltless = "did:plc:boltless"
22 keyAlpha = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICrWubjgc3IM/zqjpWQJSig6l6iFyaDx7HWTiWlasjcM"
23 keyBravo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMax5MnG4RGoxp0TlaEj8mcFhZZp13cdIIvO8s4a6KZ2"
24)
25
26func TestFetchAndStore_EmptyResponseDoesNotWipe(t *testing.T) {
27 store := newKeyStore(t)
28 seedKey(t, store, didBoltless, "seed", keyAlpha)
29
30 srv := pdsServer(t, map[string]*comatproto.RepoListRecords_Output{
31 "": page(""),
32 })
33 defer srv.Close()
34
35 if err := FetchAndStore(context.Background(), fakeDirectory{pdsURL: srv.URL}, store, didBoltless); err != nil {
36 t.Fatalf("FetchAndStore: %v", err)
37 }
38
39 owners := ownersByKey(t, store)
40 if _, ok := owners[keyAlpha]; !ok {
41 t.Fatalf("seeded key was wiped by an empty fetch response, owners=%v", owners)
42 }
43 if got := len(owners); got != 1 {
44 t.Errorf("stored %d keys, want 1", got)
45 }
46}
47
48func TestFetchAndStore_ReplacesExistingKeys(t *testing.T) {
49 store := newKeyStore(t)
50 seedKey(t, store, didBoltless, "stale", keyAlpha)
51
52 srv := pdsServer(t, map[string]*comatproto.RepoListRecords_Output{
53 "": page("", pubkeyRecord(didBoltless, "fresh", keyBravo)),
54 })
55 defer srv.Close()
56
57 if err := FetchAndStore(context.Background(), fakeDirectory{pdsURL: srv.URL}, store, didBoltless); err != nil {
58 t.Fatalf("FetchAndStore: %v", err)
59 }
60
61 owners := ownersByKey(t, store)
62 if _, ok := owners[keyAlpha]; ok {
63 t.Errorf("stale key %q survived a full replace", keyAlpha)
64 }
65 if _, ok := owners[keyBravo]; !ok {
66 t.Errorf("fresh key %q was not stored", keyBravo)
67 }
68 if got := len(owners); got != 1 {
69 t.Errorf("stored %d keys, want 1", got)
70 }
71}
72
73func TestFetchAndStore_PaginatesAcrossPages(t *testing.T) {
74 store := newKeyStore(t)
75 if err := db.AddDid(store, didBoltless); err != nil {
76 t.Fatalf("AddDid: %v", err)
77 }
78
79 srv := pdsServer(t, map[string]*comatproto.RepoListRecords_Output{
80 "": page("next", pubkeyRecord(didBoltless, "r1", keyAlpha)),
81 "next": page("", pubkeyRecord(didBoltless, "r2", keyBravo)),
82 })
83 defer srv.Close()
84
85 if err := FetchAndStore(context.Background(), fakeDirectory{pdsURL: srv.URL}, store, didBoltless); err != nil {
86 t.Fatalf("FetchAndStore: %v", err)
87 }
88
89 owners := ownersByKey(t, store)
90 if _, ok := owners[keyAlpha]; !ok {
91 t.Errorf("first-page key %q missing", keyAlpha)
92 }
93 if _, ok := owners[keyBravo]; !ok {
94 t.Errorf("second-page key %q missing", keyBravo)
95 }
96 if got := len(owners); got != 2 {
97 t.Errorf("stored %d keys, want 2", got)
98 }
99}
100
101func newKeyStore(t *testing.T) *db.DB {
102 t.Helper()
103 store, err := db.Setup(context.Background(), filepath.Join(t.TempDir(), "test.db"))
104 if err != nil {
105 t.Fatalf("db.Setup: %v", err)
106 }
107 return store
108}
109
110func seedKey(t *testing.T, store *db.DB, did syntax.DID, rkey syntax.RecordKey, key string) {
111 t.Helper()
112 if err := db.AddDid(store, did.String()); err != nil {
113 t.Fatalf("AddDid: %v", err)
114 }
115 if err := store.UpsertPublicKey(db.PublicKey{
116 Did: did,
117 Rkey: rkey,
118 PublicKey: tangled.PublicKey{Key: key, CreatedAt: "2026-06-20T00:00:00Z"},
119 }); err != nil {
120 t.Fatalf("UpsertPublicKey: %v", err)
121 }
122}
123
124func ownersByKey(t *testing.T, store *db.DB) map[string]string {
125 t.Helper()
126 rows, err := store.GetAllPublicKeys()
127 if err != nil {
128 t.Fatalf("GetAllPublicKeys: %v", err)
129 }
130 return foldOwners(rows, map[string]string{})
131}
132
133func foldOwners(rows []db.PublicKey, acc map[string]string) map[string]string {
134 if len(rows) == 0 {
135 return acc
136 }
137 acc[rows[0].Key] = rows[0].Did.String()
138 return foldOwners(rows[1:], acc)
139}
140
141func page(cursor string, records ...*comatproto.RepoListRecords_Record) *comatproto.RepoListRecords_Output {
142 out := &comatproto.RepoListRecords_Output{Records: records}
143 if cursor != "" {
144 out.Cursor = &cursor
145 }
146 return out
147}
148
149func pubkeyRecord(did, rkey, key string) *comatproto.RepoListRecords_Record {
150 return &comatproto.RepoListRecords_Record{
151 Uri: "at://" + did + "/" + tangled.PublicKeyNSID + "/" + rkey,
152 Cid: "bafyreib2rxk3rybk3aobmv5cjuql3bm2twh4jo5uxgr3rxbd3xrnp5z5cy",
153 Value: &lexutil.LexiconTypeDecoder{
154 Val: &tangled.PublicKey{Key: key, CreatedAt: "2026-06-20T00:00:00Z"},
155 },
156 }
157}
158
159func pdsServer(t *testing.T, pages map[string]*comatproto.RepoListRecords_Output) *httptest.Server {
160 t.Helper()
161 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
162 cursor := r.URL.Query().Get("cursor")
163 out, ok := pages[cursor]
164 if !ok {
165 t.Errorf("listRecords requested with unexpected cursor %q", cursor)
166 http.Error(w, "no such page", http.StatusInternalServerError)
167 return
168 }
169 w.Header().Set("Content-Type", "application/json")
170 if err := json.NewEncoder(w).Encode(out); err != nil {
171 t.Errorf("encoding listRecords response: %v", err)
172 }
173 }))
174}
175
176type fakeDirectory struct {
177 pdsURL string
178}
179
180func (f fakeDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) {
181 return &identity.Identity{
182 DID: did,
183 Services: map[string]identity.ServiceEndpoint{
184 "atproto_pds": {Type: "AtprotoPersonalDataServer", URL: f.pdsURL},
185 },
186 }, nil
187}
188
189func (f fakeDirectory) LookupHandle(ctx context.Context, handle syntax.Handle) (*identity.Identity, error) {
190 return nil, errors.New("LookupHandle unused in tests")
191}
192
193func (f fakeDirectory) Lookup(ctx context.Context, atid syntax.AtIdentifier) (*identity.Identity, error) {
194 return nil, errors.New("Lookup unused in tests")
195}
196
197func (f fakeDirectory) Purge(ctx context.Context, atid syntax.AtIdentifier) error {
198 return nil
199}