This repository has no description
1package spindle
2
3import (
4 "context"
5 "encoding/json"
6 "log/slog"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "strings"
11 "testing"
12 "time"
13
14 "tangled.org/core/jetstream"
15 "tangled.org/core/repoident"
16 "tangled.org/core/repoverify"
17
18 "github.com/bluesky-social/indigo/atproto/identity"
19 "github.com/bluesky-social/indigo/atproto/syntax"
20 "github.com/stretchr/testify/assert"
21 "tangled.org/core/api/tangled"
22 "tangled.org/core/eventconsumer"
23 "tangled.org/core/idresolver"
24 "tangled.org/core/spindle/config"
25 "tangled.org/core/spindle/db"
26
27 "tangled.org/core/tapc"
28)
29
30type mockDirectory struct {
31 ident *identity.Identity
32}
33
34func (m *mockDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) {
35 return m.ident, nil
36}
37
38func (m *mockDirectory) LookupHandle(ctx context.Context, handle syntax.Handle) (*identity.Identity, error) {
39 return m.ident, nil
40}
41
42func (m *mockDirectory) Lookup(ctx context.Context, id syntax.AtIdentifier) (*identity.Identity, error) {
43 return m.ident, nil
44}
45
46func (m *mockDirectory) Purge(ctx context.Context, id syntax.AtIdentifier) error {
47 return nil
48}
49
50func mockRepoVerifier(res repoverify.Result) repoverify.Verifier {
51 return func(ctx context.Context, repoDid repoident.RepoDid) (repoverify.Result, error) {
52 return res, nil
53 }
54}
55
56func TestProcessRepo_MembershipChange(t *testing.T) {
57 member := syntax.DID("did:example:foo")
58
59 var ok bool
60 var err error
61 d, _ := newTestSpindleDB(t)
62
63 ok, err = d.IsAllowedMember(t.Context(), member, false)
64 assert.NoError(t, err)
65 assert.False(t, ok)
66
67 ok, err = d.IsAllowedMember(t.Context(), member, true)
68 assert.NoError(t, err)
69 assert.True(t, ok)
70
71 assert.NoError(t, d.AllowMember(t.Context(), member))
72
73 ok, err = d.IsAllowedMember(t.Context(), member, false)
74 assert.NoError(t, err)
75 assert.True(t, ok)
76
77 ok, err = d.IsAllowedMember(t.Context(), member, true)
78 assert.NoError(t, err)
79 assert.True(t, ok)
80}
81
82func TestProcessRepo_MembershipCheck(t *testing.T) {
83 d, e := newTestSpindleDB(t)
84
85 cfg := &config.Config{}
86 cfg.Server.Hostname = "spindle.test"
87 cfg.Server.InviteOnly = true
88
89 ccfg := eventconsumer.NewConsumerConfig()
90 ccfg.Logger = slog.Default()
91 ks := eventconsumer.NewConsumer(*ccfg)
92
93 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
94 if jcerr != nil {
95 t.Fatalf("NewJetstreamClient: %v", jcerr)
96 }
97 s := &Spindle{
98 db: d,
99 e: e,
100 l: slog.Default(),
101 cfg: cfg,
102 ks: ks,
103 jc: jc,
104 rootCtx: context.Background(),
105 verify: mockRepoVerifier(repoverify.Result{
106 RepoDid: "did:plc:testrepo123",
107 OwnerDid: "did:plc:memberowner",
108 Rkey: "test-repo-rkey",
109 KnotURL: func() *url.URL {
110 u, err := url.Parse("https://knot.test")
111 assert.NoError(t, err)
112 return u
113 }(),
114 }),
115 }
116
117 tap := &Tap{
118 spindle: s,
119 logger: slog.Default(),
120 }
121
122 ownerDid := syntax.DID("did:plc:memberowner")
123 nonMemberDid := syntax.DID("did:plc:nonmemberowner")
124 repoDid := syntax.DID("did:plc:testrepo123")
125
126 err := d.AllowMember(t.Context(), ownerDid)
127 if err != nil {
128 t.Fatalf("AddSpindleMember: %v", err)
129 }
130
131 recNonMember := tangled.Repo{
132 Knot: "knot.test",
133 RepoDid: (*string)(&repoDid),
134 Spindle: &cfg.Server.Hostname,
135 CreatedAt: time.Now().Format(time.RFC3339),
136 }
137 recNonMemberJson, _ := json.Marshal(recNonMember)
138
139 err = tap.processRepo(context.Background(), &tapc.RecordEventData{
140 Live: true,
141 Did: nonMemberDid,
142 Rkey: "test-repo-rkey",
143 Collection: syntax.NSID(tangled.RepoNSID),
144 Action: tapc.RecordCreateAction,
145 Record: recNonMemberJson,
146 })
147 if err != nil {
148 t.Fatalf("processRepo returned error for non-member: %v", err)
149 }
150
151 _, err = d.GetRepoByDid(repoDid)
152 if err == nil {
153 t.Fatal("repo for non-member was registered in DB, expected rejection")
154 }
155
156 recMember := tangled.Repo{
157 Knot: "knot.test",
158 RepoDid: (*string)(&repoDid),
159 Spindle: &cfg.Server.Hostname,
160 CreatedAt: time.Now().Format(time.RFC3339),
161 }
162 recMemberJson, _ := json.Marshal(recMember)
163
164 err = tap.processRepo(context.Background(), &tapc.RecordEventData{
165 Live: true,
166 Did: ownerDid,
167 Rkey: "test-repo-rkey",
168 Collection: syntax.NSID(tangled.RepoNSID),
169 Action: tapc.RecordCreateAction,
170 Record: recMemberJson,
171 })
172 if err == nil {
173 t.Fatal("expected git clone error for valid member, but got nil")
174 }
175
176 if !strings.Contains(err.Error(), "setting up sparse-clone git repo") {
177 t.Fatalf("expected sparse-clone error, got: %v", err)
178 }
179}
180
181func TestProcessPull_IsCollaboratorCheck(t *testing.T) {
182 d, e := newTestSpindleDB(t)
183
184 cfg := &config.Config{}
185 cfg.Server.Hostname = "spindle.test"
186
187 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
188 if jcerr != nil {
189 t.Fatalf("NewJetstreamClient: %v", jcerr)
190 }
191 s := &Spindle{
192 db: d,
193 e: e,
194 l: slog.Default(),
195 cfg: cfg,
196 res: idresolver.DefaultResolver("https://plc.test"),
197 jc: jc,
198 rootCtx: context.Background(),
199 verify: mockRepoVerifier(repoverify.Result{
200 RepoDid: "did:plc:testrepo123",
201 OwnerDid: "did:plc:repoowner",
202 Rkey: "test-repo-rkey",
203 KnotURL: func() *url.URL {
204 u, _ := url.Parse("knot.test")
205 return u
206 }(),
207 }),
208 }
209
210 repoOwnerDid := syntax.DID("did:plc:repoowner")
211 nonPusherDid := syntax.DID("did:plc:nonpusher")
212 pusherDid := syntax.DID("did:plc:pusher")
213 repoDid := syntax.DID("did:plc:testrepo123")
214
215 err := d.UpsertRepo(db.Repo{
216 Knot: "knot.test",
217 Owner: repoOwnerDid,
218 Rkey: "test-repo-rkey",
219 RepoDid: repoDid,
220 CreatedAt: time.Now().Format(time.RFC3339),
221 })
222 if err != nil {
223 t.Fatalf("AddRepo: %v", err)
224 }
225
226 err = e.SetRepoOwner(repoOwnerDid, repoDid)
227 if err != nil {
228 t.Fatalf("AddRepo permissions: %v", err)
229 }
230 err = e.AddRepoCollaborator(pusherDid, repoDid)
231 if err != nil {
232 t.Fatalf("AddCollaborator: %v", err)
233 }
234
235 pullRecord := tangled.RepoPull{
236 Target: &tangled.RepoPull_Target{
237 Branch: "main",
238 Repo: repoDid.String(),
239 },
240 Source: &tangled.RepoPull_Source{
241 Branch: "feature",
242 Repo: nil, // branch-based PR (source repo is nil)
243 },
244 }
245 pullRecordJson, _ := json.Marshal(pullRecord)
246
247 err = s.processPull(context.Background(), &tapc.RecordEventData{
248 Live: true,
249 Did: nonPusherDid,
250 Rkey: "pull-rkey-1",
251 Collection: syntax.NSID(tangled.RepoPullNSID),
252 Action: tapc.RecordCreateAction,
253 Record: pullRecordJson,
254 })
255 if err != nil {
256 t.Fatalf("processPull returned error for non-pusher: %v", err)
257 }
258
259 // fetch fails because plc/pds are not real
260 err = s.processPull(context.Background(), &tapc.RecordEventData{
261 Live: true,
262 Did: pusherDid,
263 Rkey: "pull-rkey-2",
264 Collection: syntax.NSID(tangled.RepoPullNSID),
265 Action: tapc.RecordCreateAction,
266 Record: pullRecordJson,
267 })
268 if err == nil {
269 t.Fatal("expected error from fetchLatestSubmission for valid pusher, but got nil")
270 }
271
272 if !strings.Contains(err.Error(), "checking push access") && !strings.Contains(err.Error(), "resolve PR owner") && !strings.Contains(err.Error(), "invalid memory address") {
273 t.Fatalf("expected failed identity resolution or connection error, got: %v", err)
274 }
275}
276
277func TestProcessRepo_HijackRepoDidCheck(t *testing.T) {
278 d, e := newTestSpindleDB(t)
279
280 cfg := &config.Config{}
281 cfg.Server.Hostname = "spindle.test"
282
283 ccfg := eventconsumer.NewConsumerConfig()
284 ccfg.Logger = slog.Default()
285 ks := eventconsumer.NewConsumer(*ccfg)
286
287 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
288 if jcerr != nil {
289 t.Fatalf("NewJetstreamClient: %v", jcerr)
290 }
291 s := &Spindle{
292 db: d,
293 e: e,
294 l: slog.Default(),
295 cfg: cfg,
296 ks: ks,
297 jc: jc,
298 rootCtx: context.Background(),
299 verify: mockRepoVerifier(repoverify.Result{
300 RepoDid: "did:plc:sharedrepo",
301 OwnerDid: "did:plc:alice",
302 Rkey: "alice-repo",
303 KnotURL: func() *url.URL {
304 u, _ := url.Parse("knot.test")
305 return u
306 }(),
307 }),
308 }
309
310 tap := &Tap{
311 spindle: s,
312 logger: slog.Default(),
313 }
314
315 aliceDid := syntax.DID("did:plc:alice")
316 bobDid := syntax.DID("did:plc:bob")
317 repoDid := syntax.DID("did:plc:sharedrepo")
318
319 err := d.AllowMember(t.Context(), aliceDid)
320 if err != nil {
321 t.Fatalf("AddSpindleMember alice: %v", err)
322 }
323 err = d.AllowMember(t.Context(), bobDid)
324 if err != nil {
325 t.Fatalf("AddSpindleMember bob: %v", err)
326 }
327
328 err = d.UpsertRepo(db.Repo{
329 Knot: "knot.test",
330 Owner: aliceDid,
331 Rkey: "alice-repo",
332 RepoDid: syntax.DID(repoDid),
333 CreatedAt: time.Now().Format(time.RFC3339),
334 })
335 if err != nil {
336 t.Fatalf("d.AddRepo: %v", err)
337 }
338
339 if err := e.SetRepoOwner(aliceDid, repoDid); err != nil {
340 t.Fatalf("SetRepoOwner: %v", err)
341 }
342
343 // bob tries to register alice's repo did, must reject the hijack
344 recBob := tangled.Repo{
345 Knot: "knot.test",
346 RepoDid: (*string)(&repoDid),
347 Spindle: &cfg.Server.Hostname,
348 CreatedAt: time.Now().Format(time.RFC3339),
349 }
350 recBobJson, _ := json.Marshal(recBob)
351
352 err = tap.processRepo(context.Background(), &tapc.RecordEventData{
353 Live: true,
354 Did: bobDid,
355 Rkey: "bob-repo",
356 Collection: syntax.NSID(tangled.RepoNSID),
357 Action: tapc.RecordCreateAction,
358 Record: recBobJson,
359 })
360 if err != nil {
361 t.Fatalf("processRepo returned error on duplicate repoDid hijack attempt: %v", err)
362 }
363
364 stored, err := d.GetRepoByDid(repoDid)
365 if err != nil {
366 t.Fatalf("alice's repo row was destroyed by bob's hijack attempt: %v", err)
367 }
368 if stored.Owner != aliceDid || stored.Rkey != "alice-repo" {
369 t.Fatalf("bob hijacked alice's repoDid: owner=%s rkey=%s", stored.Owner, stored.Rkey)
370 }
371
372 // bob points the same repoDid at another spindle, which must not tear alice's repo down
373 otherSpindle := "other.test"
374 recTeardown := tangled.Repo{
375 Knot: "knot.test",
376 RepoDid: (*string)(&repoDid),
377 Spindle: &otherSpindle,
378 CreatedAt: time.Now().Format(time.RFC3339),
379 }
380 recTeardownJson, _ := json.Marshal(recTeardown)
381
382 err = tap.processRepo(context.Background(), &tapc.RecordEventData{
383 Live: true,
384 Did: bobDid,
385 Rkey: "bob-repo",
386 Collection: syntax.NSID(tangled.RepoNSID),
387 Action: tapc.RecordUpdateAction,
388 Record: recTeardownJson,
389 })
390 if err != nil {
391 t.Fatalf("processRepo returned error on forged teardown: %v", err)
392 }
393
394 if _, err := d.GetRepoByDid(repoDid); err != nil {
395 t.Fatalf("bob tore down alice's repo by naming her repoDid: %v", err)
396 }
397 ok, err := e.IsRepoOwner(aliceDid, repoDid)
398 if err != nil || !ok {
399 t.Fatal("bob's forged teardown removed alice's owner policy")
400 }
401}
402
403func TestTeardownRepo_RBAC(t *testing.T) {
404 d, e := newTestSpindleDB(t)
405
406 cfg := &config.Config{}
407 cfg.Server.Hostname = "spindle.test"
408
409 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
410 if jcerr != nil {
411 t.Fatalf("NewJetstreamClient: %v", jcerr)
412 }
413 s := &Spindle{
414 db: d,
415 e: e,
416 l: slog.Default(),
417 cfg: cfg,
418 jc: jc,
419 rootCtx: context.Background(),
420 verify: mockRepoVerifier(repoverify.Result{
421 RepoDid: "did:plc:testrepo123",
422 OwnerDid: "did:plc:repoowner",
423 Rkey: "test-repo-rkey",
424 KnotURL: func() *url.URL {
425 u, _ := url.Parse("knot.test")
426 return u
427 }(),
428 }),
429 }
430
431 tap := &Tap{
432 spindle: s,
433 logger: slog.Default(),
434 }
435
436 ownerDid := syntax.DID("did:plc:repoowner")
437 repoDid := syntax.DID("did:plc:testrepo123")
438 collabDid := syntax.DID("did:plc:collab")
439
440 err := d.UpsertRepo(db.Repo{
441 Knot: "knot.test",
442 Owner: ownerDid,
443 Rkey: "test-repo-rkey",
444 RepoDid: repoDid,
445 CreatedAt: time.Now().Format(time.RFC3339),
446 })
447 if err != nil {
448 t.Fatalf("AddRepo DB: %v", err)
449 }
450
451 err = e.SetRepoOwner(ownerDid, repoDid)
452 if err != nil {
453 t.Fatalf("AddRepo policy: %v", err)
454 }
455
456 err = e.AddRepoCollaborator(collabDid, repoDid)
457 if err != nil {
458 t.Fatalf("AddCollaborator policy: %v", err)
459 }
460
461 err = tap.processRepo(context.Background(), &tapc.RecordEventData{
462 Live: true,
463 Did: ownerDid,
464 Rkey: "test-repo-rkey",
465 Collection: syntax.NSID(tangled.RepoNSID),
466 Action: tapc.RecordDeleteAction,
467 })
468 if err != nil {
469 t.Fatalf("processRepo delete returned error: %v", err)
470 }
471
472 _, err = d.GetRepoByDid(repoDid)
473 if err == nil {
474 t.Fatal("repo remained in DB after delete")
475 }
476
477 ok, err := e.IsRepoOwner(ownerDid, repoDid)
478 if err != nil || ok {
479 t.Fatal("repo owner policy remained in Casbin after delete")
480 }
481
482 ok, err = e.IsRepoCollaborator(collabDid, repoDid)
483 if err != nil || ok {
484 t.Fatal("collaborator policy remained in Casbin after delete")
485 }
486}
487
488func TestProcessRepo_ForgeDeleteRejection(t *testing.T) {
489 d, e := newTestSpindleDB(t)
490
491 cfg := &config.Config{}
492 cfg.Server.Hostname = "spindle.test"
493
494 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
495 if jcerr != nil {
496 t.Fatalf("NewJetstreamClient: %v", jcerr)
497 }
498
499 s := &Spindle{
500 db: d,
501 e: e,
502 l: slog.Default(),
503 cfg: cfg,
504 jc: jc,
505 rootCtx: context.Background(),
506 verify: mockRepoVerifier(repoverify.Result{
507 RepoDid: "did:plc:sharedrepo",
508 OwnerDid: "did:plc:alice",
509 Rkey: "test-repo-rkey",
510 KnotURL: func() *url.URL {
511 u, _ := url.Parse("knot.test")
512 return u
513 }(),
514 }),
515 }
516
517 tap := &Tap{
518 spindle: s,
519 logger: slog.Default(),
520 }
521
522 aliceDid := syntax.DID("did:plc:alice")
523 bobDid := syntax.DID("did:plc:bob")
524 repoDid := syntax.DID("did:plc:sharedrepo")
525
526 err := d.UpsertRepo(db.Repo{
527 Knot: "knot.test",
528 Owner: aliceDid,
529 Rkey: "test-repo-rkey",
530 RepoDid: repoDid,
531 CreatedAt: time.Now().Format(time.RFC3339),
532 })
533 if err != nil {
534 t.Fatalf("AddRepo DB: %v", err)
535 }
536
537 err = e.SetRepoOwner(aliceDid, repoDid)
538 if err != nil {
539 t.Fatalf("AddRepo policy: %v", err)
540 }
541
542 // bob tries to delete alice's repo, must reject forged delete
543 err = tap.processRepo(context.Background(), &tapc.RecordEventData{
544 Live: true,
545 Did: bobDid,
546 Rkey: "test-repo-rkey",
547 Collection: syntax.NSID(tangled.RepoNSID),
548 Action: tapc.RecordDeleteAction,
549 })
550 if err != nil {
551 t.Fatalf("processRepo returned error on delete: %v", err)
552 }
553
554 _, err = d.GetRepoByDid(repoDid)
555 if err != nil {
556 t.Fatalf("Alice's repo was deleted or error: %v", err)
557 }
558
559 ok, err := e.IsRepoOwner(aliceDid, repoDid)
560 if err != nil || !ok {
561 t.Fatal("Alice's owner policy was removed from Casbin by forged delete")
562 }
563}
564
565func TestReconcileCollaborators(t *testing.T) {
566 d, e := newTestSpindleDB(t)
567
568 ownerDid := syntax.DID("did:plc:owner")
569 repoDid := syntax.DID("did:plc:repo")
570 staleDid := syntax.DID("did:plc:stale")
571 keptDid := syntax.DID("did:plc:kept")
572 newDid := syntax.DID("did:plc:new")
573
574 if err := e.SetRepoOwner(ownerDid, repoDid); err != nil {
575 t.Fatalf("SetRepoOwner: %v", err)
576 }
577 // spindle's view: one collaborator the knot dropped, one it still has
578 for _, did := range []syntax.DID{staleDid, keptDid} {
579 if err := e.AddRepoCollaborator(did, repoDid); err != nil {
580 t.Fatalf("AddRepoCollaborator(%s): %v", did, err)
581 }
582 }
583
584 // the knot's view: keptDid and a collaborator spindle never saw. Paginated, and it lists
585 // the owner too - reconcile must not treat that as an explicit grant to remove.
586 pages := [][]string{
587 {ownerDid.String(), keptDid.String()},
588 {newDid.String()},
589 }
590 var gotSubject string
591 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
592 if r.URL.Path != "/xrpc/"+tangled.RepoListCollaboratorsNSID {
593 http.NotFound(w, r)
594 return
595 }
596 gotSubject = r.URL.Query().Get("subject")
597 page := 0
598 if c := r.URL.Query().Get("cursor"); c != "" {
599 page = 1
600 }
601 out := tangled.RepoListCollaborators_Output{}
602 for _, s := range pages[page] {
603 out.Items = append(out.Items, &tangled.RepoListCollaborators_ListItem{Subject: s})
604 }
605 if page == 0 {
606 next := "page2"
607 out.Cursor = &next
608 }
609 json.NewEncoder(w).Encode(out)
610 }))
611 defer srv.Close()
612
613 cfg := &config.Config{}
614 cfg.Server.Dev = true // http, not https
615 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
616 if jcerr != nil {
617 t.Fatalf("NewJetstreamClient: %v", jcerr)
618 }
619 tap := &Tap{
620 spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg, jc: jc},
621 logger: slog.Default(),
622 }
623
624 knot := strings.TrimPrefix(srv.URL, "http://")
625 tap.reconcileCollaborators(context.Background(), slog.Default(), knot, repoDid, ownerDid)
626
627 if gotSubject != repoDid.String() {
628 t.Errorf("knot queried with subject %q, want %q", gotSubject, repoDid)
629 }
630
631 for _, tc := range []struct {
632 did syntax.DID
633 want bool
634 }{
635 {ownerDid, true}, // owner keeps access via role inheritance
636 {keptDid, true}, // still on the knot
637 {newDid, true}, // added from the knot's roster
638 {staleDid, false}, // removed on the knot while spindle was down
639 } {
640 ok, err := e.IsRepoCollaborator(tc.did, repoDid)
641 if err != nil {
642 t.Fatalf("IsRepoCollaborator(%s): %v", tc.did, err)
643 }
644 if ok != tc.want {
645 t.Errorf("IsRepoCollaborator(%s) = %v, want %v", tc.did, ok, tc.want)
646 }
647 }
648}
649
650func TestReconcileCollaboratorsKeepsGrantsOnFetchFailure(t *testing.T) {
651 d, e := newTestSpindleDB(t)
652
653 ownerDid := syntax.DID("did:plc:owner")
654 repoDid := syntax.DID("did:plc:repo")
655 collabDid := syntax.DID("did:plc:collab")
656
657 if err := e.SetRepoOwner(ownerDid, repoDid); err != nil {
658 t.Fatalf("SetRepoOwner: %v", err)
659 }
660 if err := e.AddRepoCollaborator(collabDid, repoDid); err != nil {
661 t.Fatalf("AddRepoCollaborator: %v", err)
662 }
663
664 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
665 http.Error(w, "boom", http.StatusInternalServerError)
666 }))
667 defer srv.Close()
668
669 cfg := &config.Config{}
670 cfg.Server.Dev = true
671 tap := &Tap{
672 spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg},
673 logger: slog.Default(),
674 }
675
676 tap.reconcileCollaborators(context.Background(), slog.Default(),
677 strings.TrimPrefix(srv.URL, "http://"), repoDid, ownerDid)
678
679 ok, err := e.IsRepoCollaborator(collabDid, repoDid)
680 if err != nil {
681 t.Fatalf("IsRepoCollaborator: %v", err)
682 }
683 if !ok {
684 t.Error("an unreachable knot wiped the collaborator roster")
685 }
686}