package refscan import ( "errors" "os" "os/exec" "path/filepath" "testing" "bugabinga.net/luci/internal/inbox" "bugabinga.net/luci/internal/trigger" ) func TestPollWritesBaselineWithoutEventsThenEmitsFutureRepoPush(t *testing.T) { root := t.TempDir() data := filepath.Join(root, "data") repos := filepath.Join(root, "repos") if err := os.MkdirAll(repos, 0o755); err != nil { t.Fatal(err) } ids, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}) if err != nil { t.Fatalf("Poll() baseline error = %v", err) } if len(ids) != 0 { t.Fatalf("baseline emitted %d events, want 0", len(ids)) } bare := createBareRepo(t, root, "newrepo") rev := gitOut(t, root, "--git-dir", bare, "rev-parse", "refs/heads/main") ids, err = Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}) if err != nil { t.Fatalf("Poll() new repo error = %v", err) } if len(ids) != 1 { t.Fatalf("new repo emitted %d events, want 1", len(ids)) } files, err := filepath.Glob(filepath.Join(data, "inbox", "*.json")) if err != nil { t.Fatal(err) } if len(files) != 1 { t.Fatalf("inbox files = %d, want 1", len(files)) } dataBytes, err := os.ReadFile(files[0]) if err != nil { t.Fatal(err) } want := `{"kind":"push","repo":"newrepo","old":"0000000000000000000000000000000000000000","new":"` + rev + `","ref":"refs/heads/main"}` + "\n" if string(dataBytes) != want { t.Fatalf("event = %s, want %s", dataBytes, want) } } func TestPollEmitsUpdatedRefOnce(t *testing.T) { root := t.TempDir() data := filepath.Join(root, "data") repos := filepath.Join(root, "repos") if err := os.MkdirAll(repos, 0o755); err != nil { t.Fatal(err) } bare := createBareRepo(t, root, "repo") if _, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}); err != nil { t.Fatalf("baseline poll error = %v", err) } old := gitOut(t, root, "--git-dir", bare, "rev-parse", "refs/heads/main") newRev := addCommitAndPush(t, root, "repo") ids, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}) if err != nil { t.Fatalf("update poll error = %v", err) } if len(ids) != 1 { t.Fatalf("update emitted %d events, want 1", len(ids)) } ids, err = Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}) if err != nil { t.Fatalf("repeat poll error = %v", err) } if len(ids) != 0 { t.Fatalf("repeat emitted %d events, want 0", len(ids)) } files, err := filepath.Glob(filepath.Join(data, "inbox", "*.json")) if err != nil { t.Fatal(err) } if len(files) != 1 { t.Fatalf("inbox files = %d, want 1", len(files)) } dataBytes, err := os.ReadFile(files[0]) if err != nil { t.Fatal(err) } want := `{"kind":"push","repo":"repo","old":"` + old + `","new":"` + newRev + `","ref":"refs/heads/main"}` + "\n" if string(dataBytes) != want { t.Fatalf("event = %s, want %s", dataBytes, want) } } func TestPollEmitsTagRef(t *testing.T) { root := t.TempDir() data := filepath.Join(root, "data") repos := filepath.Join(root, "repos") if err := os.MkdirAll(repos, 0o755); err != nil { t.Fatal(err) } createBareRepo(t, root, "repo") if _, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}); err != nil { t.Fatalf("baseline poll error = %v", err) } rev := tagAndPush(t, root, "repo", "v1.0.0") ids, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}) if err != nil { t.Fatalf("tag poll error = %v", err) } if len(ids) != 1 { t.Fatalf("tag emitted %d events, want 1", len(ids)) } files, err := filepath.Glob(filepath.Join(data, "inbox", "*.json")) if err != nil { t.Fatal(err) } if len(files) != 1 { t.Fatalf("inbox files = %d, want 1", len(files)) } dataBytes, err := os.ReadFile(files[0]) if err != nil { t.Fatal(err) } want := `{"kind":"push","repo":"repo","old":"0000000000000000000000000000000000000000","new":"` + rev + `","ref":"refs/tags/v1.0.0"}` + "\n" if string(dataBytes) != want { t.Fatalf("event = %s, want %s", dataBytes, want) } } func TestPollReplaysDurableOutboxWithStableEventID(t *testing.T) { root := t.TempDir() data := filepath.Join(root, "data") repos := filepath.Join(root, "repos") if err := os.MkdirAll(repos, 0o755); err != nil { t.Fatal(err) } bare := createBareRepo(t, root, "repo") cfg := PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")} if _, err := Poll(cfg); err != nil { t.Fatal(err) } addCommitAndPush(t, root, "repo") oldSave := saveRefs saveRefs = func(string, []Ref) error { return os.ErrPermission } defer func() { saveRefs = oldSave }() ids, err := Poll(cfg) if !errors.Is(err, os.ErrPermission) || len(ids) != 1 { t.Fatalf("ids=%v err=%v", ids, err) } if _, err := os.Stat(filepath.Join(data, "state", "refs.outbox.json")); err != nil { t.Fatalf("durable outbox missing: %v", err) } envelope, err := inbox.ClaimOne(cfg.InboxDir) if err != nil || envelope == nil || envelope.ID != ids[0] { t.Fatalf("envelope=%#v err=%v", envelope, err) } if err := inbox.Ack(*envelope); err != nil { t.Fatal(err) } saveRefs = oldSave ids, err = Poll(cfg) if err != nil || len(ids) != 1 || ids[0] != envelope.ID { t.Fatalf("replayed ids=%v err=%v", ids, err) } if got := gitOut(t, root, "--git-dir", bare, "rev-parse", "refs/heads/main"); got == "" { t.Fatal("repository unexpectedly lost main") } } func TestPollKeepsRepeatedRefTransitionsDistinct(t *testing.T) { root := t.TempDir() data := filepath.Join(root, "data") repos := filepath.Join(root, "repos") if err := os.MkdirAll(repos, 0o755); err != nil { t.Fatal(err) } bare := createBareRepo(t, root, "repo") cfg := PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")} if _, err := Poll(cfg); err != nil { t.Fatal(err) } old := gitOut(t, root, "--git-dir", bare, "rev-parse", "refs/heads/main") new := addCommitAndPush(t, root, "repo") var ids []string for _, target := range []string{new, old, new} { if target != new || len(ids) != 0 { runGit(t, root, "--git-dir", bare, "update-ref", "refs/heads/main", target) } got, err := Poll(cfg) if err != nil || len(got) != 1 { t.Fatalf("target=%s ids=%v err=%v", target, got, err) } ids = append(ids, got[0]) } seen := map[string]bool{} for _, id := range ids { if seen[id] { t.Fatalf("reused occurrence ID %q", id) } seen[id] = true } var transitions []trigger.PushEvent for _, id := range ids { data, err := os.ReadFile(filepath.Join(cfg.InboxDir, id+".json")) if err != nil { t.Fatal(err) } event, err := trigger.Decode(data) if err != nil { t.Fatal(err) } transitions = append(transitions, event.(trigger.PushEvent)) } if transitions[0].Old != old || transitions[0].New != new || transitions[1].Old != new || transitions[1].New != old || transitions[2].Old != old || transitions[2].New != new { t.Fatalf("transitions=%#v", transitions) } } func TestScanSortsReposAndSkipsUnsafeEntries(t *testing.T) { root := t.TempDir() repos := filepath.Join(root, "repos") if err := os.MkdirAll(repos, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, "ignored"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repos, "file.git"), []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(repos, ".git"), 0o755); err != nil { t.Fatal(err) } createBareRepo(t, root, "zeta") createBareRepo(t, root, "alpha") if err := os.MkdirAll(filepath.Join(repos, "notgit.git"), 0o755); err != nil { t.Fatal(err) } refs, err := Scan([]string{filepath.Join(root, "missing"), repos}) if err != nil { t.Fatalf("Scan() error = %v", err) } if len(refs) != 2 || refs[0].Repo != "alpha" || refs[1].Repo != "zeta" { t.Fatalf("refs = %#v", refs) } } func TestScanRejectsBadRootsAndRepos(t *testing.T) { root := t.TempDir() file := filepath.Join(root, "file") if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { t.Fatal(err) } if _, err := Scan([]string{file}); err == nil { t.Fatal("file root accepted") } repos := filepath.Join(root, "repos") if err := os.MkdirAll(filepath.Join(repos, "bad.git"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repos, "bad.git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { t.Fatal(err) } if _, err := Scan([]string{repos}); err == nil { t.Fatal("bad git repo accepted") } } func TestPollContinuesHealthyReposWhenOneRepoIsBroken(t *testing.T) { root := t.TempDir() data := filepath.Join(root, "data") repos := filepath.Join(root, "repos") if err := os.MkdirAll(filepath.Join(data, "state"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(data, "state", "refs.json"), []byte("[]"), 0o644); err != nil { t.Fatal(err) } createBareRepo(t, root, "good") if err := os.MkdirAll(filepath.Join(repos, "bad.git"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repos, "bad.git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { t.Fatal(err) } ids, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}) if err == nil { t.Fatal("broken repo error hidden") } if len(ids) != 1 { t.Fatalf("healthy repo emitted %d events, want 1: %v", len(ids), err) } } func TestPollPreservesPreviousRefsForBrokenRepo(t *testing.T) { root := t.TempDir() data := filepath.Join(root, "data") repos := filepath.Join(root, "repos") createBareRepo(t, root, "good") if _, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}); err != nil { t.Fatal(err) } refs, _, err := load(filepath.Join(data, "state", "refs.json")) if err != nil { t.Fatal(err) } refs = append(refs, Ref{Repo: "bad", Name: "refs/heads/main", Rev: "deadbeef"}) if err := save(filepath.Join(data, "state", "refs.json"), refs); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(repos, "bad.git"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repos, "bad.git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { t.Fatal(err) } addCommitAndPush(t, root, "good") if _, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(data, "inbox")}); err == nil { t.Fatal("broken repo error hidden") } current, _, err := load(filepath.Join(data, "state", "refs.json")) if err != nil { t.Fatal(err) } for _, ref := range current { if ref.Repo == "bad" && ref.Rev == "deadbeef" { return } } t.Fatalf("broken repo state lost: %#v", current) } func TestScanRepoSkipsBlankOutput(t *testing.T) { bin := t.TempDir() git := filepath.Join(bin, "git") if err := os.WriteFile(git, []byte("#!/bin/sh\nprintf '\\n'\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) refs, err := scanRepo("/repo.git", "repo") if err != nil { t.Fatal(err) } if len(refs) != 0 { t.Fatalf("refs = %#v", refs) } } func TestScanRepoRejectsBadRefLines(t *testing.T) { bin := t.TempDir() git := filepath.Join(bin, "git") if err := os.WriteFile(git, []byte("#!/bin/sh\nprintf 'badline\\n'\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) if _, err := scanRepo("/repo.git", "repo"); err == nil { t.Fatal("bad ref line accepted") } } func TestLoadSaveAndPollErrorPaths(t *testing.T) { root := t.TempDir() file := filepath.Join(root, "file") if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { t.Fatal(err) } if _, _, err := load(filepath.Join(file, "refs.json")); err == nil { t.Fatal("load under file accepted") } if err := save(filepath.Join(file, "refs.json"), nil); err == nil { t.Fatal("save under file accepted") } path := filepath.Join(root, "state", "refs.json") if err := os.MkdirAll(path, 0o755); err != nil { t.Fatal(err) } if err := save(path, nil); err == nil { t.Fatal("save over directory accepted") } writeErrPath := filepath.Join(root, "write-err", "refs.json") if err := os.MkdirAll(writeErrPath+".tmp", 0o755); err != nil { t.Fatal(err) } if err := save(writeErrPath, nil); err == nil { t.Fatal("save wrote over tmp directory") } if _, err := Poll(PollConfig{DataDir: file, RepoRoots: []string{filepath.Join(root, "missing")}, InboxDir: filepath.Join(root, "inbox")}); err == nil { t.Fatal("poll save under file accepted") } } func TestPollPropagatesScanAndInboxErrors(t *testing.T) { root := t.TempDir() data := filepath.Join(root, "data") if err := os.MkdirAll(filepath.Join(data, "state"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(data, "state", "refs.json"), []byte(`[]`), 0o644); err != nil { t.Fatal(err) } file := filepath.Join(root, "file") if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { t.Fatal(err) } if _, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{file}, InboxDir: filepath.Join(data, "inbox")}); err == nil { t.Fatal("poll scan error accepted") } repos := filepath.Join(root, "repos") if err := os.MkdirAll(repos, 0o755); err != nil { t.Fatal(err) } createBareRepo(t, root, "repo") if _, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{repos}, InboxDir: filepath.Join(file, "inbox")}); err == nil { t.Fatal("poll inbox error accepted") } } func TestSortRefsOrdersNamesWithinRepo(t *testing.T) { refs := []Ref{{Repo: "repo", Name: "refs/heads/z"}, {Repo: "repo", Name: "refs/heads/a"}} sortRefs(refs) if refs[0].Name != "refs/heads/a" { t.Fatalf("refs = %#v", refs) } } func TestPollRejectsCorruptState(t *testing.T) { root := t.TempDir() data := filepath.Join(root, "data") if err := os.MkdirAll(filepath.Join(data, "state"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(data, "state", "refs.json"), []byte(`{`), 0o644); err != nil { t.Fatal(err) } if _, err := Poll(PollConfig{DataDir: data, RepoRoots: []string{filepath.Join(root, "repos")}, InboxDir: filepath.Join(data, "inbox")}); err == nil { t.Fatal("corrupt state accepted") } } func createBareRepo(t *testing.T, root string, name string) string { t.Helper() work := filepath.Join(root, name+"-work") runGit(t, root, "init", work) runGit(t, work, "config", "user.email", "test@example.invalid") runGit(t, work, "config", "user.name", "Test") if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } runGit(t, work, "add", "README.md") runGit(t, work, "commit", "-m", "initial") runGit(t, work, "branch", "-M", "main") bare := filepath.Join(root, "repos", name+".git") runGit(t, root, "clone", "--bare", work, bare) runGit(t, work, "remote", "add", "origin", bare) return bare } func addCommitAndPush(t *testing.T, root string, name string) string { t.Helper() work := filepath.Join(root, name+"-work") if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello again\n"), 0o644); err != nil { t.Fatal(err) } runGit(t, work, "add", "README.md") runGit(t, work, "commit", "-m", "update") runGit(t, work, "push", "origin", "main") return gitOut(t, work, "rev-parse", "HEAD") } func tagAndPush(t *testing.T, root string, name string, tag string) string { t.Helper() work := filepath.Join(root, name+"-work") runGit(t, work, "tag", "-a", tag, "-m", tag) runGit(t, work, "push", "origin", tag) return gitOut(t, work, "rev-parse", tag+"^{commit}") } func runGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v failed: %v: %s", args, err, out) } } func gitOut(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v failed: %v: %s", args, err, out) } return string(out[:len(out)-1]) }