package spindle import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "tangled.org/core/jetstream" "tangled.org/core/repoident" "tangled.org/core/repoverify" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/stretchr/testify/assert" "tangled.org/core/api/tangled" "tangled.org/core/eventconsumer" "tangled.org/core/idresolver" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" "tangled.org/core/tapc" ) type mockDirectory struct { ident *identity.Identity } func (m *mockDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) { return m.ident, nil } func (m *mockDirectory) LookupHandle(ctx context.Context, handle syntax.Handle) (*identity.Identity, error) { return m.ident, nil } func (m *mockDirectory) Lookup(ctx context.Context, id syntax.AtIdentifier) (*identity.Identity, error) { return m.ident, nil } func (m *mockDirectory) Purge(ctx context.Context, id syntax.AtIdentifier) error { return nil } func mockRepoVerifier(res repoverify.Result) repoverify.Verifier { return func(ctx context.Context, repoDid repoident.RepoDid) (repoverify.Result, error) { return res, nil } } func TestProcessRepo_MembershipChange(t *testing.T) { member := syntax.DID("did:example:foo") var ok bool var err error d, _ := newTestSpindleDB(t) ok, err = d.IsAllowedMember(t.Context(), member, false) assert.NoError(t, err) assert.False(t, ok) ok, err = d.IsAllowedMember(t.Context(), member, true) assert.NoError(t, err) assert.True(t, ok) assert.NoError(t, d.AllowMember(t.Context(), member)) ok, err = d.IsAllowedMember(t.Context(), member, false) assert.NoError(t, err) assert.True(t, ok) ok, err = d.IsAllowedMember(t.Context(), member, true) assert.NoError(t, err) assert.True(t, ok) } func TestProcessRepo_MembershipCheck(t *testing.T) { d, e := newTestSpindleDB(t) cfg := &config.Config{} cfg.Server.Hostname = "spindle.test" cfg.Server.InviteOnly = true ccfg := eventconsumer.NewConsumerConfig() ccfg.Logger = slog.Default() ks := eventconsumer.NewConsumer(*ccfg) jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) if jcerr != nil { t.Fatalf("NewJetstreamClient: %v", jcerr) } s := &Spindle{ db: d, e: e, l: slog.Default(), cfg: cfg, ks: ks, jc: jc, rootCtx: context.Background(), verify: mockRepoVerifier(repoverify.Result{ RepoDid: "did:plc:testrepo123", OwnerDid: "did:plc:memberowner", Rkey: "test-repo-rkey", KnotURL: func() *url.URL { u, err := url.Parse("https://knot.test") assert.NoError(t, err) return u }(), }), } tap := &Tap{ spindle: s, logger: slog.Default(), } ownerDid := syntax.DID("did:plc:memberowner") nonMemberDid := syntax.DID("did:plc:nonmemberowner") repoDid := syntax.DID("did:plc:testrepo123") err := d.AllowMember(t.Context(), ownerDid) if err != nil { t.Fatalf("AddSpindleMember: %v", err) } recNonMember := tangled.Repo{ Knot: "knot.test", RepoDid: (*string)(&repoDid), Spindle: &cfg.Server.Hostname, CreatedAt: time.Now().Format(time.RFC3339), } recNonMemberJson, _ := json.Marshal(recNonMember) err = tap.processRepo(context.Background(), &tapc.RecordEventData{ Live: true, Did: nonMemberDid, Rkey: "test-repo-rkey", Collection: syntax.NSID(tangled.RepoNSID), Action: tapc.RecordCreateAction, Record: recNonMemberJson, }) if err != nil { t.Fatalf("processRepo returned error for non-member: %v", err) } _, err = d.GetRepoByDid(repoDid) if err == nil { t.Fatal("repo for non-member was registered in DB, expected rejection") } recMember := tangled.Repo{ Knot: "knot.test", RepoDid: (*string)(&repoDid), Spindle: &cfg.Server.Hostname, CreatedAt: time.Now().Format(time.RFC3339), } recMemberJson, _ := json.Marshal(recMember) err = tap.processRepo(context.Background(), &tapc.RecordEventData{ Live: true, Did: ownerDid, Rkey: "test-repo-rkey", Collection: syntax.NSID(tangled.RepoNSID), Action: tapc.RecordCreateAction, Record: recMemberJson, }) if err == nil { t.Fatal("expected git clone error for valid member, but got nil") } if !strings.Contains(err.Error(), "setting up sparse-clone git repo") { t.Fatalf("expected sparse-clone error, got: %v", err) } } func TestProcessPull_IsCollaboratorCheck(t *testing.T) { d, e := newTestSpindleDB(t) cfg := &config.Config{} cfg.Server.Hostname = "spindle.test" jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) if jcerr != nil { t.Fatalf("NewJetstreamClient: %v", jcerr) } s := &Spindle{ db: d, e: e, l: slog.Default(), cfg: cfg, res: idresolver.DefaultResolver("https://plc.test"), jc: jc, rootCtx: context.Background(), verify: mockRepoVerifier(repoverify.Result{ RepoDid: "did:plc:testrepo123", OwnerDid: "did:plc:repoowner", Rkey: "test-repo-rkey", KnotURL: func() *url.URL { u, _ := url.Parse("knot.test") return u }(), }), } repoOwnerDid := syntax.DID("did:plc:repoowner") nonPusherDid := syntax.DID("did:plc:nonpusher") pusherDid := syntax.DID("did:plc:pusher") repoDid := syntax.DID("did:plc:testrepo123") err := d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: repoOwnerDid, Rkey: "test-repo-rkey", RepoDid: repoDid, CreatedAt: time.Now().Format(time.RFC3339), }) if err != nil { t.Fatalf("AddRepo: %v", err) } err = e.SetRepoOwner(repoOwnerDid, repoDid) if err != nil { t.Fatalf("AddRepo permissions: %v", err) } err = e.AddRepoCollaborator(pusherDid, repoDid) if err != nil { t.Fatalf("AddCollaborator: %v", err) } pullRecord := tangled.RepoPull{ Target: &tangled.RepoPull_Target{ Branch: "main", Repo: repoDid.String(), }, Source: &tangled.RepoPull_Source{ Branch: "feature", Repo: nil, // branch-based PR (source repo is nil) }, } pullRecordJson, _ := json.Marshal(pullRecord) err = s.processPull(context.Background(), &tapc.RecordEventData{ Live: true, Did: nonPusherDid, Rkey: "pull-rkey-1", Collection: syntax.NSID(tangled.RepoPullNSID), Action: tapc.RecordCreateAction, Record: pullRecordJson, }) if err != nil { t.Fatalf("processPull returned error for non-pusher: %v", err) } // fetch fails because plc/pds are not real err = s.processPull(context.Background(), &tapc.RecordEventData{ Live: true, Did: pusherDid, Rkey: "pull-rkey-2", Collection: syntax.NSID(tangled.RepoPullNSID), Action: tapc.RecordCreateAction, Record: pullRecordJson, }) if err == nil { t.Fatal("expected error from fetchLatestSubmission for valid pusher, but got nil") } if !strings.Contains(err.Error(), "checking push access") && !strings.Contains(err.Error(), "resolve PR owner") && !strings.Contains(err.Error(), "invalid memory address") { t.Fatalf("expected failed identity resolution or connection error, got: %v", err) } } func TestProcessRepo_HijackRepoDidCheck(t *testing.T) { d, e := newTestSpindleDB(t) cfg := &config.Config{} cfg.Server.Hostname = "spindle.test" ccfg := eventconsumer.NewConsumerConfig() ccfg.Logger = slog.Default() ks := eventconsumer.NewConsumer(*ccfg) jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) if jcerr != nil { t.Fatalf("NewJetstreamClient: %v", jcerr) } s := &Spindle{ db: d, e: e, l: slog.Default(), cfg: cfg, ks: ks, jc: jc, rootCtx: context.Background(), verify: mockRepoVerifier(repoverify.Result{ RepoDid: "did:plc:sharedrepo", OwnerDid: "did:plc:alice", Rkey: "alice-repo", KnotURL: func() *url.URL { u, _ := url.Parse("knot.test") return u }(), }), } tap := &Tap{ spindle: s, logger: slog.Default(), } aliceDid := syntax.DID("did:plc:alice") bobDid := syntax.DID("did:plc:bob") repoDid := syntax.DID("did:plc:sharedrepo") err := d.AllowMember(t.Context(), aliceDid) if err != nil { t.Fatalf("AddSpindleMember alice: %v", err) } err = d.AllowMember(t.Context(), bobDid) if err != nil { t.Fatalf("AddSpindleMember bob: %v", err) } err = d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: aliceDid, Rkey: "alice-repo", RepoDid: syntax.DID(repoDid), CreatedAt: time.Now().Format(time.RFC3339), }) if err != nil { t.Fatalf("d.AddRepo: %v", err) } if err := e.SetRepoOwner(aliceDid, repoDid); err != nil { t.Fatalf("SetRepoOwner: %v", err) } // bob tries to register alice's repo did, must reject the hijack recBob := tangled.Repo{ Knot: "knot.test", RepoDid: (*string)(&repoDid), Spindle: &cfg.Server.Hostname, CreatedAt: time.Now().Format(time.RFC3339), } recBobJson, _ := json.Marshal(recBob) err = tap.processRepo(context.Background(), &tapc.RecordEventData{ Live: true, Did: bobDid, Rkey: "bob-repo", Collection: syntax.NSID(tangled.RepoNSID), Action: tapc.RecordCreateAction, Record: recBobJson, }) if err != nil { t.Fatalf("processRepo returned error on duplicate repoDid hijack attempt: %v", err) } stored, err := d.GetRepoByDid(repoDid) if err != nil { t.Fatalf("alice's repo row was destroyed by bob's hijack attempt: %v", err) } if stored.Owner != aliceDid || stored.Rkey != "alice-repo" { t.Fatalf("bob hijacked alice's repoDid: owner=%s rkey=%s", stored.Owner, stored.Rkey) } // bob points the same repoDid at another spindle, which must not tear alice's repo down otherSpindle := "other.test" recTeardown := tangled.Repo{ Knot: "knot.test", RepoDid: (*string)(&repoDid), Spindle: &otherSpindle, CreatedAt: time.Now().Format(time.RFC3339), } recTeardownJson, _ := json.Marshal(recTeardown) err = tap.processRepo(context.Background(), &tapc.RecordEventData{ Live: true, Did: bobDid, Rkey: "bob-repo", Collection: syntax.NSID(tangled.RepoNSID), Action: tapc.RecordUpdateAction, Record: recTeardownJson, }) if err != nil { t.Fatalf("processRepo returned error on forged teardown: %v", err) } if _, err := d.GetRepoByDid(repoDid); err != nil { t.Fatalf("bob tore down alice's repo by naming her repoDid: %v", err) } ok, err := e.IsRepoOwner(aliceDid, repoDid) if err != nil || !ok { t.Fatal("bob's forged teardown removed alice's owner policy") } } func TestTeardownRepo_RBAC(t *testing.T) { d, e := newTestSpindleDB(t) cfg := &config.Config{} cfg.Server.Hostname = "spindle.test" jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) if jcerr != nil { t.Fatalf("NewJetstreamClient: %v", jcerr) } s := &Spindle{ db: d, e: e, l: slog.Default(), cfg: cfg, jc: jc, rootCtx: context.Background(), verify: mockRepoVerifier(repoverify.Result{ RepoDid: "did:plc:testrepo123", OwnerDid: "did:plc:repoowner", Rkey: "test-repo-rkey", KnotURL: func() *url.URL { u, _ := url.Parse("knot.test") return u }(), }), } tap := &Tap{ spindle: s, logger: slog.Default(), } ownerDid := syntax.DID("did:plc:repoowner") repoDid := syntax.DID("did:plc:testrepo123") collabDid := syntax.DID("did:plc:collab") err := d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: ownerDid, Rkey: "test-repo-rkey", RepoDid: repoDid, CreatedAt: time.Now().Format(time.RFC3339), }) if err != nil { t.Fatalf("AddRepo DB: %v", err) } err = e.SetRepoOwner(ownerDid, repoDid) if err != nil { t.Fatalf("AddRepo policy: %v", err) } err = e.AddRepoCollaborator(collabDid, repoDid) if err != nil { t.Fatalf("AddCollaborator policy: %v", err) } err = tap.processRepo(context.Background(), &tapc.RecordEventData{ Live: true, Did: ownerDid, Rkey: "test-repo-rkey", Collection: syntax.NSID(tangled.RepoNSID), Action: tapc.RecordDeleteAction, }) if err != nil { t.Fatalf("processRepo delete returned error: %v", err) } _, err = d.GetRepoByDid(repoDid) if err == nil { t.Fatal("repo remained in DB after delete") } ok, err := e.IsRepoOwner(ownerDid, repoDid) if err != nil || ok { t.Fatal("repo owner policy remained in Casbin after delete") } ok, err = e.IsRepoCollaborator(collabDid, repoDid) if err != nil || ok { t.Fatal("collaborator policy remained in Casbin after delete") } } func TestProcessRepo_ForgeDeleteRejection(t *testing.T) { d, e := newTestSpindleDB(t) cfg := &config.Config{} cfg.Server.Hostname = "spindle.test" jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) if jcerr != nil { t.Fatalf("NewJetstreamClient: %v", jcerr) } s := &Spindle{ db: d, e: e, l: slog.Default(), cfg: cfg, jc: jc, rootCtx: context.Background(), verify: mockRepoVerifier(repoverify.Result{ RepoDid: "did:plc:sharedrepo", OwnerDid: "did:plc:alice", Rkey: "test-repo-rkey", KnotURL: func() *url.URL { u, _ := url.Parse("knot.test") return u }(), }), } tap := &Tap{ spindle: s, logger: slog.Default(), } aliceDid := syntax.DID("did:plc:alice") bobDid := syntax.DID("did:plc:bob") repoDid := syntax.DID("did:plc:sharedrepo") err := d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: aliceDid, Rkey: "test-repo-rkey", RepoDid: repoDid, CreatedAt: time.Now().Format(time.RFC3339), }) if err != nil { t.Fatalf("AddRepo DB: %v", err) } err = e.SetRepoOwner(aliceDid, repoDid) if err != nil { t.Fatalf("AddRepo policy: %v", err) } // bob tries to delete alice's repo, must reject forged delete err = tap.processRepo(context.Background(), &tapc.RecordEventData{ Live: true, Did: bobDid, Rkey: "test-repo-rkey", Collection: syntax.NSID(tangled.RepoNSID), Action: tapc.RecordDeleteAction, }) if err != nil { t.Fatalf("processRepo returned error on delete: %v", err) } _, err = d.GetRepoByDid(repoDid) if err != nil { t.Fatalf("Alice's repo was deleted or error: %v", err) } ok, err := e.IsRepoOwner(aliceDid, repoDid) if err != nil || !ok { t.Fatal("Alice's owner policy was removed from Casbin by forged delete") } } func TestReconcileCollaborators(t *testing.T) { d, e := newTestSpindleDB(t) ownerDid := syntax.DID("did:plc:owner") repoDid := syntax.DID("did:plc:repo") staleDid := syntax.DID("did:plc:stale") keptDid := syntax.DID("did:plc:kept") newDid := syntax.DID("did:plc:new") if err := e.SetRepoOwner(ownerDid, repoDid); err != nil { t.Fatalf("SetRepoOwner: %v", err) } // spindle's view: one collaborator the knot dropped, one it still has for _, did := range []syntax.DID{staleDid, keptDid} { if err := e.AddRepoCollaborator(did, repoDid); err != nil { t.Fatalf("AddRepoCollaborator(%s): %v", did, err) } } // the knot's view: keptDid and a collaborator spindle never saw. Paginated, and it lists // the owner too - reconcile must not treat that as an explicit grant to remove. pages := [][]string{ {ownerDid.String(), keptDid.String()}, {newDid.String()}, } var gotSubject string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/xrpc/"+tangled.RepoListCollaboratorsNSID { http.NotFound(w, r) return } gotSubject = r.URL.Query().Get("subject") page := 0 if c := r.URL.Query().Get("cursor"); c != "" { page = 1 } out := tangled.RepoListCollaborators_Output{} for _, s := range pages[page] { out.Items = append(out.Items, &tangled.RepoListCollaborators_ListItem{Subject: s}) } if page == 0 { next := "page2" out.Cursor = &next } json.NewEncoder(w).Encode(out) })) defer srv.Close() cfg := &config.Config{} cfg.Server.Dev = true // http, not https jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) if jcerr != nil { t.Fatalf("NewJetstreamClient: %v", jcerr) } tap := &Tap{ spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg, jc: jc}, logger: slog.Default(), } knot := strings.TrimPrefix(srv.URL, "http://") tap.reconcileCollaborators(context.Background(), slog.Default(), knot, repoDid, ownerDid) if gotSubject != repoDid.String() { t.Errorf("knot queried with subject %q, want %q", gotSubject, repoDid) } for _, tc := range []struct { did syntax.DID want bool }{ {ownerDid, true}, // owner keeps access via role inheritance {keptDid, true}, // still on the knot {newDid, true}, // added from the knot's roster {staleDid, false}, // removed on the knot while spindle was down } { ok, err := e.IsRepoCollaborator(tc.did, repoDid) if err != nil { t.Fatalf("IsRepoCollaborator(%s): %v", tc.did, err) } if ok != tc.want { t.Errorf("IsRepoCollaborator(%s) = %v, want %v", tc.did, ok, tc.want) } } } func TestReconcileCollaboratorsKeepsGrantsOnFetchFailure(t *testing.T) { d, e := newTestSpindleDB(t) ownerDid := syntax.DID("did:plc:owner") repoDid := syntax.DID("did:plc:repo") collabDid := syntax.DID("did:plc:collab") if err := e.SetRepoOwner(ownerDid, repoDid); err != nil { t.Fatalf("SetRepoOwner: %v", err) } if err := e.AddRepoCollaborator(collabDid, repoDid); err != nil { t.Fatalf("AddRepoCollaborator: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) })) defer srv.Close() cfg := &config.Config{} cfg.Server.Dev = true tap := &Tap{ spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg}, logger: slog.Default(), } tap.reconcileCollaborators(context.Background(), slog.Default(), strings.TrimPrefix(srv.URL, "http://"), repoDid, ownerDid) ok, err := e.IsRepoCollaborator(collabDid, repoDid) if err != nil { t.Fatalf("IsRepoCollaborator: %v", err) } if !ok { t.Error("an unreachable knot wiped the collaborator roster") } }