This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / spindle / tapclient_test.go
18 kB 674 lines
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}