Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422package spindle
import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "slices" "strings" "testing"
"github.com/bluesky-social/indigo/atproto/syntax" cbg "github.com/whyrusleeping/cbor-gen" "tangled.org/core/knotfeed" "tangled.org/core/rbac" "tangled.org/core/spindle/db" "tangled.org/core/spindle/webhook" "tangled.org/core/tapc")
const ( testKnot = "knot.nel.pet" testForeign = "barnacle.nel.pet" testRepoDid = syntax.DID("did:plc:limpet") testSubject = syntax.DID("did:plc:boltless") testRkeyMain = syntax.RecordKey("refs~2fheads~2fmain") testHexOld = "1111111111111111111111111111111111111111" testHexNew = "2222222222222222222222222222222222222222")
var ( testShaOld = mustObjectID(testHexOld) testShaNew = mustObjectID(testHexNew))
func mustObjectID(hex string) knotfeed.ObjectID { sha, ok := knotfeed.ParseObjectID(hex) if !ok { panic("test fixture " + hex + " is not an object id") } return sha}
func newTestFeedSpindle(t *testing.T) *Spindle { t.Helper() d, e := newTestSpindleDB(t) quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) s := &Spindle{db: d, e: e, l: quiet, wh: webhook.New(d, false)} if err := d.AddRepo(db.Repo{Knot: testKnot, Owner: "did:plc:akshay", Rkey: "3kqrstuvwxyz", RepoDid: testRepoDid}); err != nil { t.Fatalf("AddRepo: %v", err) } return s}
func feedCommit(repo syntax.DID, ops ...knotfeed.RecordOp) knotfeed.Message { return knotfeed.Message{ Type: knotfeed.TypeCommit, Commit: &knotfeed.Commit{ Repo: repo, Seq: 42, Records: ops, }, }}
func permissionsOf(t *testing.T, s *Spindle, repoDid syntax.DID) []string { t.Helper() perms := slices.Sorted(slices.Values(s.e.GetPermissionsInRepo(testSubject.String(), rbac.ThisServer, repoDid.String()))) return perms}
func TestKnotFeedReconcileCollaborators(t *testing.T) { cases := []struct { name string desired []syntax.DID seed bool granted bool }{ {"a listed collaborator is granted", []syntax.DID{testSubject}, false, true}, {"a tracked collaborator absent from the knot is revoked", nil, true, false}, {"a collaborator the knot still lists keeps the grant", []syntax.DID{testSubject}, true, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s := newTestFeedSpindle(t) if tc.seed { if err := s.e.AddCollaborator(testSubject.String(), rbac.ThisServer, testRepoDid.String()); err != nil { t.Fatalf("seed policy: %v", err) } if err := s.db.AddKnotCollaborator(testRepoDid, testSubject); err != nil { t.Fatalf("seed row: %v", err) } } s.listCollaborators = func(ctx context.Context, knot string, repoDid syntax.DID) ([]syntax.DID, error) { return tc.desired, nil }
if err := s.reconcileCollaborators(context.Background(), testKnot, testRepoDid); err != nil { t.Fatalf("reconcileCollaborators: %v", err) }
want := []string(nil) if tc.granted { want = []string{"repo:collaborator", "repo:push", "repo:settings"} } if perms := permissionsOf(t, s, testRepoDid); !slices.Equal(perms, want) { t.Errorf("permissions = %v, want %v", perms, want) } held := slices.ContainsFunc(mustCollaborators(t, s), func(c db.RepoCollaborator) bool { return c.Subject == testSubject }) if held != tc.granted { t.Errorf("repo_collaborators row present = %v, want %v", held, tc.granted) } }) }}
func mustCollaborators(t *testing.T, s *Spindle) []db.RepoCollaborator { t.Helper() collabs, err := s.db.ListCollaboratorsByRepoDid(testRepoDid) if err != nil { t.Fatalf("ListCollaboratorsByRepoDid: %v", err) } return collabs}
func TestKnotFeedReconcileIgnoresForeignKnotAndUnknownRepo(t *testing.T) { s := newTestFeedSpindle(t) var asked int s.listCollaborators = func(ctx context.Context, knot string, repoDid syntax.DID) ([]syntax.DID, error) { asked++ return []syntax.DID{testSubject}, nil }
if err := s.reconcileCollaborators(context.Background(), testForeign, testRepoDid); err != nil { t.Fatalf("foreign knot reconcile: %v", err) } if err := s.reconcileCollaborators(context.Background(), testKnot, syntax.DID("did:plc:unknown")); err != nil { t.Fatalf("unknown repo reconcile: %v", err) } if asked != 0 { t.Errorf("listCollaborators asked %d times, want 0", asked) } if perms := permissionsOf(t, s, testRepoDid); len(perms) != 0 { t.Errorf("permissions = %v, want none", perms) }}
func TestKnotFeedRefOps(t *testing.T) { t.Run("a delete op drops the ref and triggers nothing", func(t *testing.T) { s := newTestFeedSpindle(t) s.listRefRecords = func(ctx context.Context, knot string, repoDid syntax.DID) ([]refRecord, error) { return []refRecord{{Rkey: testRkeyMain, Sha: testShaOld}}, nil } if err := s.db.PutFeedRef(testRepoDid, testRkeyMain, testShaOld); err != nil { t.Fatalf("seed ref state: %v", err) }
if err := s.handleKnotFeed(context.Background(), testKnot, feedCommit(testRepoDid, knotfeed.RecordOp{Action: tapc.RecordDeleteAction, Collection: knotfeed.GitRefCollection, Rkey: testRkeyMain}, )); err != nil { t.Fatalf("handleKnotFeed: %v", err) } if _, ok, err := s.db.FeedRefSha(testRepoDid, testRkeyMain); err != nil || ok { t.Errorf("ref state row present = %v (err %v), want gone", ok, err) } })
t.Run("a skip-ci push updates ref state and triggers nothing", func(t *testing.T) { s := newTestFeedSpindle(t) s.listRefRecords = func(ctx context.Context, knot string, repoDid syntax.DID) ([]refRecord, error) { return nil, nil } if err := s.db.PutFeedRef(testRepoDid, testRkeyMain, testShaOld); err != nil { t.Fatalf("seed ref state: %v", err) }
if err := s.handleKnotFeed(context.Background(), testKnot, feedCommit(testRepoDid, knotfeed.RecordOp{Action: tapc.RecordCreateAction, Collection: knotfeed.GitRefCollection, Rkey: testRkeyMain, Bytes: encodeRefRecord(testHexNew, "skip-ci")}, )); err != nil { t.Fatalf("handleKnotFeed: %v", err) } if sha, _, err := s.db.FeedRefSha(testRepoDid, testRkeyMain); err != nil || sha != testShaNew { t.Errorf("ref state sha = %q (err %v), want %q", sha, err, testShaNew) } })
t.Run("a create op that never materializes skips bootstrap", func(t *testing.T) { for _, tc := range []struct { name string repo syntax.DID rkey syntax.RecordKey }{ {"unknown repo", "did:plc:unknown", testRkeyMain}, {"ref outside heads and tags", testRepoDid, "refs~2fnotes~2fwip"}, } { t.Run(tc.name, func(t *testing.T) { s := newTestFeedSpindle(t) s.listRefRecords = func(ctx context.Context, knot string, repoDid syntax.DID) ([]refRecord, error) { t.Errorf("bootstrap ran for %s", tc.name) return nil, nil }
if err := s.handleKnotFeed(context.Background(), testKnot, feedCommit(tc.repo, knotfeed.RecordOp{Action: tapc.RecordCreateAction, Collection: knotfeed.GitRefCollection, Rkey: tc.rkey, Bytes: encodeRefRecord(testHexNew)}, )); err != nil { t.Fatalf("handleKnotFeed: %v", err) } }) } })
t.Run("an undecodable record is an error rather than a silent drop", func(t *testing.T) { s := newTestFeedSpindle(t) s.listRefRecords = func(ctx context.Context, knot string, repoDid syntax.DID) ([]refRecord, error) { return nil, nil }
if err := s.handleKnotFeed(context.Background(), testKnot, feedCommit(testRepoDid, knotfeed.RecordOp{Action: tapc.RecordCreateAction, Collection: knotfeed.GitRefCollection, Rkey: testRkeyMain, Bytes: []byte{0xff}}, )); err == nil { t.Fatal("expected an error for an undecodable ref record") } })
t.Run("a collaboratorInvite op reconciles the repo", func(t *testing.T) { s := newTestFeedSpindle(t) var reconciled bool s.listCollaborators = func(ctx context.Context, knot string, repoDid syntax.DID) ([]syntax.DID, error) { reconciled = true return nil, nil }
if err := s.handleKnotFeed(context.Background(), testKnot, feedCommit(testRepoDid, knotfeed.RecordOp{Action: tapc.RecordCreateAction, Collection: collaboratorInviteNSID, Rkey: syntax.RecordKey(testSubject)}, )); err != nil { t.Fatalf("handleKnotFeed: %v", err) } if !reconciled { t.Error("collaboratorInvite didn't run a reconcile") } })}
func TestKnotFeedPriorState(t *testing.T) { t.Run("a ref we have never seen has no prior and asks the knot nothing", func(t *testing.T) { s := newTestFeedSpindle(t) s.listRefRecords = func(context.Context, string, syntax.DID) ([]refRecord, error) { t.Fatal("resolving a prior sha fetched the knot's refs") return nil, nil }
for _, action := range []tapc.RecordAction{tapc.RecordCreateAction, tapc.RecordUpdateAction} { sha, err := s.priorSha(testRepoDid, knotfeed.RecordOp{Action: action, Rkey: testRkeyMain}) if err != nil || !sha.IsZero() { t.Errorf("%s prior sha = (%q, %v), want zero and nil", action, sha, err) } } })
t.Run("the recorded state answers an op that reports none", func(t *testing.T) { s := newTestFeedSpindle(t) if err := s.db.PutFeedRef(testRepoDid, testRkeyMain, testShaOld); err != nil { t.Fatalf("record ref state: %v", err) }
sha, err := s.priorSha(testRepoDid, knotfeed.RecordOp{Action: tapc.RecordUpdateAction, Rkey: testRkeyMain}) if err != nil || sha != testShaOld { t.Errorf("prior sha = (%q, %v), want the recorded %q", sha, err, testShaOld) } })
t.Run("the reported prior wins over the recorded state", func(t *testing.T) { s := newTestFeedSpindle(t) if err := s.db.PutFeedRef(testRepoDid, testRkeyMain, testShaOld); err != nil { t.Fatalf("record ref state: %v", err) }
sha, err := s.priorSha(testRepoDid, knotfeed.RecordOp{ Action: tapc.RecordUpdateAction, Rkey: testRkeyMain, Prior: knotfeed.ParsePriorSha(testHexNew), }) if err != nil || sha != testShaNew { t.Errorf("prior sha = (%q, %v), want the reported %q", sha, err, testShaNew) } })}
func TestAdmitChangedFilesUnderBudget(t *testing.T) { shaSized := func(n int) []string { paths := make([]string, n) for i := range n { paths[i] = "src/f.go" } return paths }
if got := admitChangedFiles([]string{"a.go", "b/c.go"}); !slices.Equal(got, []string{"a.go", "b/c.go"}) { t.Errorf("small listing = %v, want both paths", got) } if got := admitChangedFiles(shaSized(11000)); got != nil { t.Errorf("listing over the entry cap = %v, want nil", got) } wide := shaSized(8192) if got := admitChangedFiles(wide); !slices.Equal(got, wide) { t.Errorf("listing at the entry cap = %d paths, want all %d", len(got), len(wide)) } big := make([]string, 11000) for i := range big { big[i] = string(make([]byte, 64)) } if got := admitChangedFiles(big); got != nil { t.Errorf("listing over the byte budget = %d paths, want nil", len(got)) }}
func TestChangedFilesUnderBudgetGuardsFirstSight(t *testing.T) { quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) if got := changedFilesUnderBudget(quiet, "/nonexistent/repo", knotfeed.ObjectID{}, testShaNew); got != nil { t.Errorf("first sight of a ref = %v, want nil without touching the repo", got) }}
func encodeRefRecord(sha string, options ...string) []byte { var out bytes.Buffer writeText := func(s string) { cbg.CborWriteHeader(&out, cbg.MajTextString, uint64(len(s))) out.WriteString(s) } entries := uint64(1) if len(options) > 0 { entries = 2 } cbg.CborWriteHeader(&out, cbg.MajMap, entries) writeText("sha") writeText(sha) if len(options) > 0 { writeText("x-tngl-push-options") cbg.CborWriteHeader(&out, cbg.MajArray, uint64(len(options))) for _, option := range options { writeText(option) } } return out.Bytes()}
func TestChangedFilesUnderBudgetWithRealRepo(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } repoPath := filepath.Join(t.TempDir(), "repo") run := func(args ...string) string { out, err := exec.Command("git", args...).CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } return strings.TrimSpace(string(out)) } run("init", "-q", "-b", "main", repoPath) run("-C", repoPath, "config", "user.email", "ci@nel.pet") run("-C", repoPath, "config", "user.name", "ci") write := func(name, content string) { path := filepath.Join(repoPath, name) os.MkdirAll(filepath.Dir(path), 0o755) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) } } commit := func() knotfeed.ObjectID { run("-C", repoPath, "add", "-A") run("-C", repoPath, "commit", "-q", "-m", "wip") return mustObjectID(run("-C", repoPath, "rev-parse", "HEAD")) }
write("README.md", "one\n") shaFirst := commit() write(".tangled/workflows/ci.yaml", "when:\n event: push\n") write("src/app.go", "package main\n") shaSecond := commit() run("-C", repoPath, "mv", "src/app.go", "src/main.go") shaThird := commit()
quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) for _, tc := range []struct { name string oldSha knotfeed.ObjectID newSha knotfeed.ObjectID want []string }{ {"a push commit lists its added and modified files", shaFirst, shaSecond, []string{".tangled/workflows/ci.yaml", "src/app.go"}}, {"a rename lists both the old and new path", shaSecond, shaThird, []string{"src/app.go", "src/main.go"}}, {"a no-op revision range lists nothing", shaThird, shaThird, nil}, } { t.Run(tc.name, func(t *testing.T) { got := changedFilesUnderBudget(quiet, repoPath, tc.oldSha, tc.newSha) slices.Sort(got) if !slices.Equal(got, tc.want) { t.Errorf("changed files = %v, want %v", got, tc.want) } }) }}
func TestCollaboratorListBailsPastMaxListPages(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"items": []any{}, "cursor": "more"}) })) defer srv.Close()
s := &Spindle{} host := "localhost:" + strings.TrimPrefix(srv.Listener.Addr().String(), "127.0.0.1:") if _, err := s.collaboratorList(context.Background(), host, testRepoDid); err == nil { t.Fatal("collaboratorList answered no error past the page cap") }}