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