This repository has no description
17 kB
491 lines
1package xrpc
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "io"
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "path/filepath"
12 "testing"
13
14 "github.com/bluesky-social/indigo/atproto/syntax"
15 "tangled.org/core/api/tangled"
16 "tangled.org/core/idresolver"
17 "tangled.org/core/knotserver/config"
18 "tangled.org/core/knotserver/db"
19 "tangled.org/core/rbac"
20)
21
22const (
23 aclOwner = "did:plc:akshay"
24 aclSubject = "did:plc:boltless"
25 aclRepoDid = "did:plc:limpet"
26)
27
28type fakeIngester struct {
29 added []string
30 removed []string
31}
32
33func (f *fakeIngester) AddDid(did string) { f.added = append(f.added, did) }
34func (f *fakeIngester) RemoveDid(did string) { f.removed = append(f.removed, did) }
35
36func newACLXrpc(t *testing.T) (*Xrpc, *fakeIngester) {
37 t.Helper()
38 dir := t.TempDir()
39 d, err := db.Setup(context.Background(), filepath.Join(dir, "knot.db"))
40 if err != nil {
41 t.Fatalf("db.Setup: %v", err)
42 }
43 e, err := rbac.NewEnforcer(filepath.Join(dir, "rbac.db"))
44 if err != nil {
45 t.Fatalf("NewEnforcer: %v", err)
46 }
47 if err := e.AddKnot(rbac.ThisServer); err != nil {
48 t.Fatalf("AddKnot: %v", err)
49 }
50 if err := e.AddKnotOwner(rbac.ThisServer, aclOwner); err != nil {
51 t.Fatalf("AddKnotOwner: %v", err)
52 }
53 ing := &fakeIngester{}
54 x := &Xrpc{
55 Db: d,
56 Enforcer: e,
57 Ingester: ing,
58 Resolver: idresolver.DefaultResolver("http://127.0.0.1:1"),
59 Config: &config.Config{Server: config.Server{Hostname: "knot.example", MaxResponseKB: 5120}},
60 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
61 }
62 return x, ing
63}
64
65func aclRequest(t *testing.T, actor string, body any) *http.Request {
66 t.Helper()
67 var buf bytes.Buffer
68 if body != nil {
69 if err := json.NewEncoder(&buf).Encode(body); err != nil {
70 t.Fatalf("encode body: %v", err)
71 }
72 }
73 req := httptest.NewRequest(http.MethodPost, "/xrpc/test", &buf)
74 if actor != "" {
75 req = req.WithContext(context.WithValue(req.Context(), ActorDid, syntax.DID(actor)))
76 }
77 return req
78}
79
80func seedRepo(t *testing.T, x *Xrpc) {
81 t.Helper()
82 if err := x.Db.StoreRepoKey(aclRepoDid, []byte("signing"), aclOwner, "reponame"); err != nil {
83 t.Fatalf("StoreRepoKey: %v", err)
84 }
85 if err := x.Enforcer.AddRepo(aclOwner, rbac.ThisServer, aclRepoDid); err != nil {
86 t.Fatalf("AddRepo: %v", err)
87 }
88}
89
90func TestAddMember_HappyPath(t *testing.T) {
91 x, ing := newACLXrpc(t)
92 rec := httptest.NewRecorder()
93 x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject}))
94
95 if rec.Code != http.StatusOK {
96 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
97 }
98 if n, err := db.CountKnotMembersBySubject(x.Db, aclSubject); err != nil || n != 1 {
99 t.Errorf("member rows = %d (err %v), want 1", n, err)
100 }
101 isM, err := x.Enforcer.IsKnotMember(aclSubject, rbac.ThisServer)
102 if err != nil || !isM {
103 t.Errorf("IsKnotMember = %v (err %v), want true", isM, err)
104 }
105 if len(ing.added) != 1 || ing.added[0] != aclSubject {
106 t.Errorf("ingester.added = %v, want [%s]", ing.added, aclSubject)
107 }
108}
109
110func TestAddMember_AlreadyMemberShortCircuits(t *testing.T) {
111 x, ing := newACLXrpc(t)
112 for i := 0; i < 2; i++ {
113 rec := httptest.NewRecorder()
114 x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject}))
115 if rec.Code != http.StatusOK {
116 t.Fatalf("add %d status = %d, want 200", i, rec.Code)
117 }
118 }
119 if n, _ := db.CountKnotMembersBySubject(x.Db, aclSubject); n != 1 {
120 t.Errorf("member rows = %d, want 1 after duplicate add", n)
121 }
122 if len(ing.added) != 1 {
123 t.Errorf("ingester.added = %v, want a single entry; second add must short-circuit", ing.added)
124 }
125}
126
127func TestAddMember_MissingActorForbidden(t *testing.T) {
128 x, _ := newACLXrpc(t)
129 rec := httptest.NewRecorder()
130 x.AddMember(rec, aclRequest(t, "", tangled.KnotAddMember_Input{Subject: aclSubject}))
131 if rec.Code != http.StatusForbidden {
132 t.Errorf("status = %d, want 403", rec.Code)
133 }
134}
135
136func TestAddMember_NonOwnerForbidden(t *testing.T) {
137 x, _ := newACLXrpc(t)
138 rec := httptest.NewRecorder()
139 x.AddMember(rec, aclRequest(t, aclSubject, tangled.KnotAddMember_Input{Subject: "did:plc:scallop"}))
140 if rec.Code != http.StatusForbidden {
141 t.Errorf("status = %d, want 403", rec.Code)
142 }
143}
144
145func TestAddMember_MalformedSubjectBadRequest(t *testing.T) {
146 x, _ := newACLXrpc(t)
147 rec := httptest.NewRecorder()
148 x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: "notadid"}))
149 if rec.Code != http.StatusBadRequest {
150 t.Errorf("status = %d, want 400", rec.Code)
151 }
152}
153
154func TestRemoveMember_HappyPathClearsBothStores(t *testing.T) {
155 x, ing := newACLXrpc(t)
156 addRec := httptest.NewRecorder()
157 x.AddMember(addRec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject}))
158 if addRec.Code != http.StatusOK {
159 t.Fatalf("setup add status = %d", addRec.Code)
160 }
161
162 rec := httptest.NewRecorder()
163 x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclSubject}))
164 if rec.Code != http.StatusOK {
165 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
166 }
167 if n, _ := db.CountKnotMembersBySubject(x.Db, aclSubject); n != 0 {
168 t.Errorf("member rows = %d, want 0", n)
169 }
170 if isM, _ := x.Enforcer.IsKnotMember(aclSubject, rbac.ThisServer); isM {
171 t.Error("IsKnotMember still true after remove")
172 }
173 if len(ing.removed) != 1 || ing.removed[0] != aclSubject {
174 t.Errorf("ingester.removed = %v, want [%s]", ing.removed, aclSubject)
175 }
176}
177
178func TestRemoveMember_KeepsDidWhenOtherPolicyExists(t *testing.T) {
179 x, ing := newACLXrpc(t)
180 addRec := httptest.NewRecorder()
181 x.AddMember(addRec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject}))
182 if addRec.Code != http.StatusOK {
183 t.Fatalf("setup add status = %d", addRec.Code)
184 }
185 if err := x.Enforcer.AddRepo(aclSubject, rbac.ThisServer, aclRepoDid); err != nil {
186 t.Fatalf("AddRepo: %v", err)
187 }
188
189 rec := httptest.NewRecorder()
190 x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclSubject}))
191 if rec.Code != http.StatusOK {
192 t.Fatalf("status = %d, want 200", rec.Code)
193 }
194 if len(ing.removed) != 0 {
195 t.Errorf("ingester.removed = %v, want empty; did still has repo policy", ing.removed)
196 }
197}
198
199func TestRemoveMember_NonMemberNoop(t *testing.T) {
200 x, _ := newACLXrpc(t)
201 rec := httptest.NewRecorder()
202 x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclSubject}))
203 if rec.Code != http.StatusOK {
204 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
205 }
206 if isM, _ := x.Enforcer.IsKnotMember(aclSubject, rbac.ThisServer); isM {
207 t.Error("non-member became a member after remove no-op")
208 }
209}
210
211func TestRemoveCollaborator_NonCollaboratorNoop(t *testing.T) {
212 x, _ := newACLXrpc(t)
213 seedRepo(t, x)
214 rec := httptest.NewRecorder()
215 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject}))
216 if rec.Code != http.StatusOK {
217 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
218 }
219 if isC, _ := x.Enforcer.IsRepoCollaborator(aclSubject, rbac.ThisServer, aclRepoDid); isC {
220 t.Error("non-collaborator became a collaborator after remove no-op")
221 }
222}
223
224func TestAddCollaborator_HappyPath(t *testing.T) {
225 x, ing := newACLXrpc(t)
226 seedRepo(t, x)
227
228 rec := httptest.NewRecorder()
229 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject}))
230 if rec.Code != http.StatusOK {
231 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
232 }
233 list, _, err := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit})
234 if err != nil || len(list) != 1 || list[0].Subject != syntax.DID(aclSubject) {
235 t.Errorf("collaborators = %+v (err %v), want one boltless", list, err)
236 }
237 if isC, _ := x.Enforcer.IsRepoCollaborator(aclSubject, rbac.ThisServer, aclRepoDid); !isC {
238 t.Error("IsRepoCollaborator false after add")
239 }
240 if len(ing.added) != 1 || ing.added[0] != aclSubject {
241 t.Errorf("ingester.added = %v", ing.added)
242 }
243}
244
245func TestAddCollaborator_MalformedRepoBadRequest(t *testing.T) {
246 x, _ := newACLXrpc(t)
247 seedRepo(t, x)
248 rec := httptest.NewRecorder()
249 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: "notadid", Subject: aclSubject}))
250 if rec.Code != http.StatusBadRequest {
251 t.Errorf("status = %d, want 400", rec.Code)
252 }
253}
254
255func TestAddCollaborator_UnknownRepoNotFound(t *testing.T) {
256 x, _ := newACLXrpc(t)
257 rec := httptest.NewRecorder()
258 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: "did:plc:scallop", Subject: aclSubject}))
259 if rec.Code != http.StatusNotFound {
260 t.Errorf("status = %d, want 404", rec.Code)
261 }
262}
263
264func TestAddCollaborator_NoInvitePermissionForbidden(t *testing.T) {
265 x, _ := newACLXrpc(t)
266 if err := x.Db.StoreRepoKey(aclRepoDid, []byte("signing"), aclOwner, "reponame"); err != nil {
267 t.Fatalf("StoreRepoKey: %v", err)
268 }
269 rec := httptest.NewRecorder()
270 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject}))
271 if rec.Code != http.StatusForbidden {
272 t.Errorf("status = %d, want 403", rec.Code)
273 }
274}
275
276func TestRemoveCollaborator_HappyPathClearsBothStores(t *testing.T) {
277 x, ing := newACLXrpc(t)
278 seedRepo(t, x)
279 addRec := httptest.NewRecorder()
280 x.AddCollaborator(addRec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject}))
281 if addRec.Code != http.StatusOK {
282 t.Fatalf("setup add status = %d", addRec.Code)
283 }
284
285 rec := httptest.NewRecorder()
286 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject}))
287 if rec.Code != http.StatusOK {
288 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
289 }
290 if list, _, _ := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit}); len(list) != 0 {
291 t.Errorf("collaborators = %d, want 0", len(list))
292 }
293 if isC, _ := x.Enforcer.IsRepoCollaborator(aclSubject, rbac.ThisServer, aclRepoDid); isC {
294 t.Error("IsRepoCollaborator still true after remove")
295 }
296 if len(ing.removed) != 1 || ing.removed[0] != aclSubject {
297 t.Errorf("ingester.removed = %v", ing.removed)
298 }
299}
300
301func TestRemoveCollaborator_MalformedRepoBadRequest(t *testing.T) {
302 x, _ := newACLXrpc(t)
303 rec := httptest.NewRecorder()
304 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: "notadid", Subject: aclSubject}))
305 if rec.Code != http.StatusBadRequest {
306 t.Errorf("status = %d, want 400", rec.Code)
307 }
308}
309
310func TestRemoveCollaborator_UnknownRepoNotFound(t *testing.T) {
311 x, _ := newACLXrpc(t)
312 rec := httptest.NewRecorder()
313 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: "did:plc:scallop", Subject: aclSubject}))
314 if rec.Code != http.StatusNotFound {
315 t.Errorf("status = %d, want 404", rec.Code)
316 }
317}
318
319func TestRemoveCollaborator_OwnerKeepsRepoRights(t *testing.T) {
320 x, _ := newACLXrpc(t)
321 seedRepo(t, x)
322
323 if ok, _ := x.Enforcer.IsSettingsAllowed(aclOwner, rbac.ThisServer, aclRepoDid); !ok {
324 t.Fatal("precondition: owner lacks repo:settings")
325 }
326 if ok, _ := x.Enforcer.IsPushAllowed(aclOwner, rbac.ThisServer, aclRepoDid); !ok {
327 t.Fatal("precondition: owner lacks repo:push")
328 }
329
330 rec := httptest.NewRecorder()
331 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: aclRepoDid, Subject: aclOwner}))
332 if rec.Code != http.StatusOK {
333 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
334 }
335 if ok, _ := x.Enforcer.IsSettingsAllowed(aclOwner, rbac.ThisServer, aclRepoDid); !ok {
336 t.Error("owner lost repo:settings after removeCollaborator(owner)")
337 }
338 if ok, _ := x.Enforcer.IsPushAllowed(aclOwner, rbac.ThisServer, aclRepoDid); !ok {
339 t.Error("owner lost repo:push after removeCollaborator(owner)")
340 }
341 if ok, _ := x.Enforcer.IsRepoOwner(aclOwner, rbac.ThisServer, aclRepoDid); !ok {
342 t.Error("owner lost repo:owner after removeCollaborator(owner)")
343 }
344}
345
346func TestAddCollaborator_OwnerIsNoOp(t *testing.T) {
347 x, ing := newACLXrpc(t)
348 seedRepo(t, x)
349
350 rec := httptest.NewRecorder()
351 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclOwner}))
352 if rec.Code != http.StatusOK {
353 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
354 }
355 if list, _, _ := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit}); len(list) != 0 {
356 t.Errorf("collaborators = %d, want 0; owner must not become a redundant collaborator row", len(list))
357 }
358 if len(ing.added) != 0 {
359 t.Errorf("ingester.added = %v, want empty for an owner no-op", ing.added)
360 }
361}
362
363func TestRemoveMember_OwnerRejected(t *testing.T) {
364 x, _ := newACLXrpc(t)
365 rec := httptest.NewRecorder()
366 x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclOwner}))
367 if rec.Code != http.StatusBadRequest {
368 t.Fatalf("status = %d, want 400 for removing the owner", rec.Code)
369 }
370 if isOwner, _ := x.Enforcer.IsKnotOwner(aclOwner, rbac.ThisServer); !isOwner {
371 t.Error("owner demoted by removeMember")
372 }
373 if isM, _ := x.Enforcer.IsKnotMember(aclOwner, rbac.ThisServer); !isM {
374 t.Error("owner lost membership after removeMember")
375 }
376}
377
378func TestAddMember_HealsCasbinOnlyDrift(t *testing.T) {
379 x, _ := newACLXrpc(t)
380 if _, err := x.Enforcer.TryAddKnotMember(rbac.ThisServer, aclSubject); err != nil {
381 t.Fatalf("seed casbin-only membership: %v", err)
382 }
383 if n, _ := db.CountKnotMembersBySubject(x.Db, aclSubject); n != 0 {
384 t.Fatalf("precondition: table already has %d rows", n)
385 }
386
387 rec := httptest.NewRecorder()
388 x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject}))
389 if rec.Code != http.StatusOK {
390 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
391 }
392 if n, _ := db.CountKnotMembersBySubject(x.Db, aclSubject); n != 1 {
393 t.Errorf("member rows = %d, want 1; canonical table must be healed", n)
394 }
395}
396
397func TestAddCollaborator_HealsCasbinOnlyDrift(t *testing.T) {
398 x, _ := newACLXrpc(t)
399 seedRepo(t, x)
400 if err := x.Enforcer.AddCollaborator(aclSubject, rbac.ThisServer, aclRepoDid); err != nil {
401 t.Fatalf("seed casbin-only collaborator: %v", err)
402 }
403 if list, _, _ := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit}); len(list) != 0 {
404 t.Fatalf("precondition: table already has %d rows", len(list))
405 }
406
407 rec := httptest.NewRecorder()
408 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject}))
409 if rec.Code != http.StatusOK {
410 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
411 }
412 if list, _, _ := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit}); len(list) != 1 {
413 t.Errorf("collaborators = %d, want 1; canonical table must be healed", len(list))
414 }
415}
416
417func adminAddReq(t *testing.T, user, pass, subject string) *http.Request {
418 t.Helper()
419 var buf bytes.Buffer
420 if err := json.NewEncoder(&buf).Encode(tangled.KnotAddMember_Input{Subject: subject}); err != nil {
421 t.Fatalf("encode body: %v", err)
422 }
423 req := httptest.NewRequest(http.MethodPost, "/addMember", &buf)
424 if user != "" || pass != "" {
425 req.SetBasicAuth(user, pass)
426 }
427 return req
428}
429
430func TestAddMemberAdmin_RejectsBadCredentials(t *testing.T) {
431 x, _ := newACLXrpc(t)
432 x.Config.Server.AdminSecret = "hunter2"
433 x.Config.Server.Owner = aclOwner
434 srv := x.AdminRouter()
435
436 cases := []struct {
437 name string
438 user string
439 pass string
440 }{
441 {"no credentials", "", ""},
442 {"wrong secret", "admin", "nope"},
443 {"wrong user", "root", "hunter2"},
444 }
445 for _, c := range cases {
446 t.Run(c.name, func(t *testing.T) {
447 rec := httptest.NewRecorder()
448 srv.ServeHTTP(rec, adminAddReq(t, c.user, c.pass, "did:plc:whelk"))
449 if rec.Code != http.StatusUnauthorized {
450 t.Fatalf("status = %d, want 401", rec.Code)
451 }
452 if n, _ := db.CountKnotMembersBySubject(x.Db, "did:plc:whelk"); n != 0 {
453 t.Errorf("member rows = %d, want 0; bad credentials must not add", n)
454 }
455 })
456 }
457}
458
459func TestAddMemberAdmin_RejectsWhenSecretUnset(t *testing.T) {
460 x, _ := newACLXrpc(t)
461 x.Config.Server.Owner = aclOwner
462 srv := x.AdminRouter()
463
464 rec := httptest.NewRecorder()
465 srv.ServeHTTP(rec, adminAddReq(t, "admin", "hunter2", "did:plc:whelk"))
466 if rec.Code != http.StatusUnauthorized {
467 t.Fatalf("status = %d, want 401; admin api must stay closed with no secret configured", rec.Code)
468 }
469}
470
471func TestAddMemberAdmin_AddsMember(t *testing.T) {
472 x, ing := newACLXrpc(t)
473 x.Config.Server.AdminSecret = "hunter2"
474 x.Config.Server.Owner = aclOwner
475 srv := x.AdminRouter()
476
477 rec := httptest.NewRecorder()
478 srv.ServeHTTP(rec, adminAddReq(t, "admin", "hunter2", "did:plc:whelk"))
479 if rec.Code != http.StatusOK {
480 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
481 }
482 if n, _ := db.CountKnotMembersBySubject(x.Db, "did:plc:whelk"); n != 1 {
483 t.Errorf("member rows = %d, want 1 after admin add", n)
484 }
485 if isM, _ := x.Enforcer.IsKnotMember("did:plc:whelk", rbac.ThisServer); !isM {
486 t.Error("IsKnotMember false after admin add")
487 }
488 if len(ing.added) != 1 || ing.added[0] != "did:plc:whelk" {
489 t.Errorf("ingester.added = %v, want [did:plc:whelk]", ing.added)
490 }
491}