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