Monorepo for Tangled
tangled.org
1package db
2
3import (
4 "context"
5 "path/filepath"
6 "slices"
7 "testing"
8
9 "github.com/bluesky-social/indigo/atproto/syntax"
10)
11
12func newACLTestDB(t *testing.T) *DB {
13 t.Helper()
14 d, err := Setup(context.Background(), filepath.Join(t.TempDir(), "knot.db"))
15 if err != nil {
16 t.Fatalf("Setup: %v", err)
17 }
18 return d
19}
20
21func countMembers(t *testing.T, d *DB, where string, args ...any) int {
22 t.Helper()
23 var n int
24 if err := d.QueryRow("select count(1) from knot_members where "+where, args...).Scan(&n); err != nil {
25 t.Fatalf("count members: %v", err)
26 }
27 return n
28}
29
30func seedLegacyMember(t *testing.T, d *DB, owner, rkey, subject string) {
31 t.Helper()
32 if _, err := d.Exec(
33 `insert into knot_members (did, rkey, subject) values (?, ?, ?)`,
34 owner, rkey, subject,
35 ); err != nil {
36 t.Fatalf("seed legacy member: %v", err)
37 }
38}
39
40func TestAddKnotMemberDirect_IdempotentUnderPartialUnique(t *testing.T) {
41 d := newACLTestDB(t)
42 owner := syntax.DID("did:plc:akshay")
43 subject := syntax.DID("did:plc:boltless")
44
45 if err := AddKnotMemberDirect(d, owner, subject); err != nil {
46 t.Fatalf("first add: %v", err)
47 }
48 if err := AddKnotMemberDirect(d, owner, subject); err != nil {
49 t.Fatalf("second add: %v", err)
50 }
51
52 if got := countMembers(t, d, "subject = ? and rkey is null", subject); got != 1 {
53 t.Errorf("direct rows = %d, want 1", got)
54 }
55}
56
57func TestRemoveKnotMemberDirect_PreservesLegacyRow(t *testing.T) {
58 d := newACLTestDB(t)
59 owner := syntax.DID("did:plc:akshay")
60 subject := syntax.DID("did:plc:boltless")
61
62 seedLegacyMember(t, d, owner.String(), "legacy-rk", subject.String())
63 if err := AddKnotMemberDirect(d, owner, subject); err != nil {
64 t.Fatalf("direct add: %v", err)
65 }
66 if got := countMembers(t, d, "subject = ?", subject.String()); got != 2 {
67 t.Fatalf("rows = %d, want 2 legacy plus direct", got)
68 }
69
70 if err := RemoveKnotMemberDirect(d, subject); err != nil {
71 t.Fatalf("remove direct: %v", err)
72 }
73 if got := countMembers(t, d, "subject = ? and rkey is null", subject.String()); got != 0 {
74 t.Errorf("direct rows after remove = %d, want 0", got)
75 }
76 if got := countMembers(t, d, "subject = ? and rkey = 'legacy-rk'", subject.String()); got != 1 {
77 t.Errorf("legacy row = %d, want 1 preserved", got)
78 }
79}
80
81func TestRemoveKnotMemberBySubject_RemovesAllRows(t *testing.T) {
82 d := newACLTestDB(t)
83 owner := syntax.DID("did:plc:akshay")
84 subject := syntax.DID("did:plc:boltless")
85
86 seedLegacyMember(t, d, owner.String(), "legacy-rk", subject.String())
87 if err := AddKnotMemberDirect(d, owner, subject); err != nil {
88 t.Fatalf("direct add: %v", err)
89 }
90
91 if err := RemoveKnotMemberBySubject(d, subject); err != nil {
92 t.Fatalf("remove by subject: %v", err)
93 }
94 if got := countMembers(t, d, "subject = ?", subject.String()); got != 0 {
95 t.Errorf("rows after remove = %d, want 0", got)
96 }
97}
98
99func TestCollaborators_AddListRemoveScopedByRepo(t *testing.T) {
100 d := newACLTestDB(t)
101 owner := syntax.DID("did:plc:akshay")
102 subject := syntax.DID("did:plc:boltless")
103 repoA := syntax.DID("did:plc:limpet")
104 repoB := syntax.DID("did:plc:scallop")
105
106 for _, repo := range []syntax.DID{repoA, repoB} {
107 if err := AddCollaborator(d, Collaborator{RepoDid: repo, Subject: subject, AddedBy: owner}); err != nil {
108 t.Fatalf("add collaborator on %s: %v", repo, err)
109 }
110 }
111
112 listA, _, err := ListCollaborators(d, repoA, ListPage{Limit: ListMaxLimit})
113 if err != nil {
114 t.Fatalf("list A: %v", err)
115 }
116 if len(listA) != 1 || listA[0].Subject != subject || listA[0].RepoDid != repoA {
117 t.Fatalf("repoA collaborators = %+v, want one boltless on limpet", listA)
118 }
119
120 if err := RemoveCollaborator(d, repoA, subject); err != nil {
121 t.Fatalf("remove on A: %v", err)
122 }
123 if listA, _, _ = ListCollaborators(d, repoA, ListPage{Limit: ListMaxLimit}); len(listA) != 0 {
124 t.Errorf("repoA after remove = %d, want 0", len(listA))
125 }
126 if listB, _, _ := ListCollaborators(d, repoB, ListPage{Limit: ListMaxLimit}); len(listB) != 1 {
127 t.Errorf("repoB after removing from A = %d, want 1 scoped", len(listB))
128 }
129}
130
131func TestAddCollaborator_IdempotentUnderUnique(t *testing.T) {
132 d := newACLTestDB(t)
133 owner := syntax.DID("did:plc:akshay")
134 subject := syntax.DID("did:plc:boltless")
135 repo := syntax.DID("did:plc:limpet")
136
137 if err := AddCollaborator(d, Collaborator{RepoDid: repo, Subject: subject, AddedBy: owner}); err != nil {
138 t.Fatalf("first add: %v", err)
139 }
140 if err := AddCollaborator(d, Collaborator{RepoDid: repo, Subject: subject, AddedBy: owner}); err != nil {
141 t.Fatalf("second add: %v", err)
142 }
143
144 list, _, err := ListCollaborators(d, repo, ListPage{Limit: ListMaxLimit})
145 if err != nil {
146 t.Fatalf("list: %v", err)
147 }
148 if len(list) != 1 {
149 t.Errorf("rows = %d, want 1", len(list))
150 }
151}
152
153func TestListKnotMembers_DedupsLegacyAndDirect(t *testing.T) {
154 d := newACLTestDB(t)
155 owner := syntax.DID("did:plc:akshay")
156 subject := syntax.DID("did:plc:boltless")
157
158 seedLegacyMember(t, d, owner.String(), "legacy-rk", subject.String())
159 if err := AddKnotMemberDirect(d, owner, subject); err != nil {
160 t.Fatalf("direct add: %v", err)
161 }
162 if got := countMembers(t, d, "subject = ?", subject.String()); got != 2 {
163 t.Fatalf("raw rows = %d, want 2 (legacy + direct)", got)
164 }
165
166 members, next, err := ListKnotMembers(d, ListPage{Limit: ListMaxLimit})
167 if err != nil {
168 t.Fatalf("list: %v", err)
169 }
170 if len(members) != 1 {
171 t.Fatalf("members = %d, want 1; legacy and direct rows for one subject must collapse", len(members))
172 }
173 if members[0].Subject != subject {
174 t.Errorf("subject = %s, want %s", members[0].Subject, subject)
175 }
176 if next != nil {
177 t.Errorf("cursor = %v, want nil for a complete page", *next)
178 }
179}
180
181func TestListKnotMembers_OrderAndKeyset(t *testing.T) {
182 d := newACLTestDB(t)
183 owner := syntax.DID("did:plc:akshay")
184 for _, s := range []string{"did:plc:limpet", "did:plc:whelk", "did:plc:scallop"} {
185 if err := AddKnotMemberDirect(d, owner, syntax.DID(s)); err != nil {
186 t.Fatalf("add %s: %v", s, err)
187 }
188 }
189
190 asc, _, err := ListKnotMembers(d, ListPage{Limit: ListMaxLimit, Desc: false})
191 if err != nil {
192 t.Fatalf("asc: %v", err)
193 }
194 if !slices.IsSortedFunc(asc, func(a, b KnotMember) int { return a.Id - b.Id }) {
195 t.Errorf("asc not ascending by id: %+v", asc)
196 }
197
198 desc, _, err := ListKnotMembers(d, ListPage{Limit: ListMaxLimit, Desc: true})
199 if err != nil {
200 t.Fatalf("desc: %v", err)
201 }
202 if !slices.IsSortedFunc(desc, func(a, b KnotMember) int { return b.Id - a.Id }) {
203 t.Errorf("desc not descending by id: %+v", desc)
204 }
205
206 page1, next, err := ListKnotMembers(d, ListPage{Limit: 2, Desc: false})
207 if err != nil {
208 t.Fatalf("page1: %v", err)
209 }
210 if len(page1) != 2 || next == nil {
211 t.Fatalf("page1 = %d next=%v, want 2 rows and a cursor", len(page1), next)
212 }
213 page2, next2, err := ListKnotMembers(d, ListPage{Limit: 2, Cursor: next, Desc: false})
214 if err != nil {
215 t.Fatalf("page2: %v", err)
216 }
217 if len(page2) != 1 || next2 != nil {
218 t.Fatalf("page2 = %d next=%v, want 1 row and no cursor", len(page2), next2)
219 }
220 if page1[len(page1)-1].Id >= page2[0].Id {
221 t.Errorf("keyset overlap or gap: page1 last id %d, page2 first id %d", page1[len(page1)-1].Id, page2[0].Id)
222 }
223}
224
225func TestListKnotMembers_ZeroValuePageDefaultsLimit(t *testing.T) {
226 d := newACLTestDB(t)
227 owner := syntax.DID("did:plc:akshay")
228 for _, s := range []string{"did:plc:limpet", "did:plc:whelk"} {
229 if err := AddKnotMemberDirect(d, owner, syntax.DID(s)); err != nil {
230 t.Fatalf("add %s: %v", s, err)
231 }
232 }
233
234 for _, p := range []ListPage{{}, {Limit: -3}} {
235 members, next, err := ListKnotMembers(d, p)
236 if err != nil {
237 t.Fatalf("list with page %+v: %v", p, err)
238 }
239 if len(members) != 2 {
240 t.Errorf("page %+v: members = %d, want 2 under the default limit", p, len(members))
241 }
242 if next != nil {
243 t.Errorf("page %+v: cursor = %v, want nil", p, *next)
244 }
245 }
246}
247
248func TestDeleteRepoKeyRemovesCollaborators(t *testing.T) {
249 d := newACLTestDB(t)
250
251 repoDid := syntax.DID("did:plc:whelk")
252 owner := syntax.DID("did:plc:akshay")
253 subject := syntax.DID("did:plc:boltless")
254
255 if err := d.StoreRepoKey(repoDid.String(), []byte("signing"), owner.String(), "reponame"); err != nil {
256 t.Fatalf("StoreRepoKey: %v", err)
257 }
258 if err := AddCollaborator(d, Collaborator{RepoDid: repoDid, Subject: subject, AddedBy: owner}); err != nil {
259 t.Fatalf("AddCollaborator: %v", err)
260 }
261 if ok, err := IsCollaborator(d, repoDid, subject); err != nil || !ok {
262 t.Fatalf("collaborator missing before delete: ok=%v err=%v", ok, err)
263 }
264
265 if err := d.DeleteRepoKey(repoDid.String()); err != nil {
266 t.Fatalf("DeleteRepoKey: %v", err)
267 }
268
269 if ok, err := IsCollaborator(d, repoDid, subject); err != nil || ok {
270 t.Fatalf("collaborator not removed after repo delete: ok=%v err=%v", ok, err)
271 }
272}