internal/control/mrprune_test.go

v1.37.0
gitbay/internal/control/mrprune_test.go history · blame · raw

221 lines · 7858 bytes

  1package control
  2
  3import (
  4	"bytes"
  5	"os"
  6	"os/exec"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10
 11	"gitbay.org/gitbay/internal/config"
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16// prunedRepo builds a repository whose one merged MR's head is reachable
 17// from nothing but refs/merge-requests/1/head: main never contained it
 18// and the feature branch is deleted. That is the reachability a history
 19// rewrite leaves behind, and the only case the command is for.
 20func prunedRepo(t *testing.T) (*store.Store, store.Repo, string, string) {
 21	t.Helper()
 22	st, repo, uid := newQueueTestRepo(t)
 23	git := gitRunner(t)
 24	root := t.TempDir()
 25	src := filepath.Join(root, "src")
 26	os.MkdirAll(src, 0o755)
 27	git(root, "init", "-q", "-b", "main", "src")
 28	os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644)
 29	git(src, "add", ".")
 30	git(src, "commit", "-q", "-m", "base")
 31	git(src, "checkout", "-q", "-b", "feature")
 32	os.WriteFile(filepath.Join(src, "README"), []byte("y\n"), 0o644)
 33	git(src, "add", ".")
 34	git(src, "commit", "-q", "-m", "change")
 35	headSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
 36
 37	dir := RepoDir(root, repo.OwnerName, repo.Name)
 38	os.MkdirAll(filepath.Dir(dir), 0o755)
 39	git(root, "clone", "-q", "--bare", src, dir)
 40	git(dir, "symbolic-ref", "HEAD", "refs/heads/main")
 41	git(dir, "update-ref", mrHeadRef(1), headSHA)
 42	git(dir, "update-ref", "-d", "refs/heads/feature")
 43
 44	if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", headSHA, "md", false); err != nil {
 45		t.Fatal(err)
 46	}
 47	mr, err := st.MRByNumber(repo.ID, 1)
 48	if err != nil {
 49		t.Fatal(err)
 50	}
 51	if err := st.MarkMerged(mr.ID, headSHA, uid, ""); err != nil {
 52		t.Fatal(err)
 53	}
 54	return st, repo, root, headSHA
 55}
 56
 57// rootUser creates an instance admin in the store, so the system comment
 58// and audit row it writes have a real author.
 59func rootUser(t *testing.T, st *store.Store) store.User {
 60	t.Helper()
 61	id, err := st.CreateUser("root", true)
 62	if err != nil {
 63		t.Fatal(err)
 64	}
 65	return store.User{ID: id, Username: "root", IsAdmin: true}
 66}
 67
 68func pruneCtx(st *store.Store, root string, user store.User) (*Ctx, *bytes.Buffer) {
 69	var errOut bytes.Buffer
 70	c := &Ctx{User: user, Scope: "full", Store: st, Stdout: &bytes.Buffer{}, Stderr: &errOut}
 71	c.Cfg.Server = config.Server{Root: root}
 72	return c, &errOut
 73}
 74
 75func objectExists(dir, sha string) bool {
 76	cmd := exec.Command("git", "-C", dir, "cat-file", "-e", sha)
 77	cmd.Env = gitTestEnv()
 78	return cmd.Run() == nil
 79}
 80
 81func refExists(dir, ref string) bool {
 82	cmd := exec.Command("git", "-C", dir, "show-ref", "--verify", "--quiet", ref)
 83	cmd.Env = gitTestEnv()
 84	return cmd.Run() == nil
 85}
 86
 87func TestAdminMRPruneDropsHeadAndObjects(t *testing.T) {
 88	st, repo, root, headSHA := prunedRepo(t)
 89	dir := RepoDir(root, repo.OwnerName, repo.Name)
 90	c, errOut := pruneCtx(st, root, rootUser(t, st))
 91
 92	if code := Dispatch(c, []string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}); code != protocol.ExitOK {
 93		t.Fatalf("exit %d: %s", code, errOut.String())
 94	}
 95	if refExists(dir, mrHeadRef(1)) {
 96		t.Error("refs/merge-requests/1/head still exists")
 97	}
 98	if objectExists(dir, headSHA) {
 99		t.Error("the head commit is still in the object store; gc --prune=now did not run")
100	}
101	mr, _ := st.MRByNumber(repo.ID, 1)
102	comments, err := st.ListMRComments(mr.ID)
103	if err != nil {
104		t.Fatal(err)
105	}
106	if len(comments) != 1 || !strings.Contains(comments[0].Body, "pruned") {
107		t.Errorf("want one system comment saying the head was pruned, got %+v", comments)
108	}
109	entries, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "admin mr.prune", Limit: 10})
110	if err != nil {
111		t.Fatal(err)
112	}
113	if len(entries) != 1 {
114		t.Errorf("want one audit row, got %d", len(entries))
115	}
116}
117
118func TestAdminMRPruneTreatsMissingRefAsDone(t *testing.T) {
119	st, repo, root, _ := prunedRepo(t)
120	dir := RepoDir(root, repo.OwnerName, repo.Name)
121	gitRunner(t)(dir, "update-ref", "-d", mrHeadRef(1))
122	c, errOut := pruneCtx(st, root, rootUser(t, st))
123	if code := Dispatch(c, []string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}); code != protocol.ExitOK {
124		t.Fatalf("exit %d: %s", code, errOut.String())
125	}
126}
127
128// Every refusal happens before any write: the ref and the objects are
129// untouched afterwards, even for the number that was valid.
130func TestAdminMRPruneRefusals(t *testing.T) {
131	cases := []struct {
132		name  string
133		admin bool
134		args  []string
135		want  int
136		msg   string
137	}{
138		{"non-admin", false, []string{"1", "--yes"}, protocol.ExitDenied, "instance admins"},
139		{"no --yes", true, []string{"1"}, protocol.ExitUsage, "--yes"},
140		{"no numbers", true, []string{"--yes"}, protocol.ExitUsage, "usage"},
141		{"not a number", true, []string{"x", "--yes"}, protocol.ExitUsage, "usage"},
142		{"unknown MR", true, []string{"1", "7", "--yes"}, protocol.ExitNotFound, "!7 not found"},
143	}
144	for _, tc := range cases {
145		t.Run(tc.name, func(t *testing.T) {
146			st, repo, root, headSHA := prunedRepo(t)
147			dir := RepoDir(root, repo.OwnerName, repo.Name)
148			user := store.User{ID: 1, Username: "alice"}
149			if tc.admin {
150				user = rootUser(t, st)
151			}
152			c, errOut := pruneCtx(st, root, user)
153			argv := append([]string{"admin", "mr", "prune", repo.Path()}, tc.args...)
154			if code := Dispatch(c, argv); code != tc.want {
155				t.Fatalf("exit %d, want %d: %s", code, tc.want, errOut.String())
156			}
157			if !strings.Contains(errOut.String(), tc.msg) {
158				t.Errorf("stderr %q does not mention %q", errOut.String(), tc.msg)
159			}
160			if !refExists(dir, mrHeadRef(1)) || !objectExists(dir, headSHA) {
161				t.Error("a refused call touched the repository")
162			}
163		})
164	}
165}
166
167// An open or source-gone MR is still mergeable, and its head ref is what
168// makes it so.
169func TestAdminMRPruneRefusesMergeableMR(t *testing.T) {
170	for _, state := range []string{"open", "source_gone"} {
171		t.Run(state, func(t *testing.T) {
172			st, repo, root, headSHA := prunedRepo(t)
173			dir := RepoDir(root, repo.OwnerName, repo.Name)
174			mr, _ := st.MRByNumber(repo.ID, 1)
175			if err := st.SetMRState(mr.ID, state); err != nil {
176				t.Fatal(err)
177			}
178			c, errOut := pruneCtx(st, root, rootUser(t, st))
179			if code := Dispatch(c, []string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}); code != protocol.ExitFailure {
180				t.Fatalf("exit %d, want %d: %s", code, protocol.ExitFailure, errOut.String())
181			}
182			if !strings.Contains(errOut.String(), "still mergeable") {
183				t.Errorf("stderr %q does not say why", errOut.String())
184			}
185			if !refExists(dir, mrHeadRef(1)) || !objectExists(dir, headSHA) {
186				t.Error("a refused call touched the repository")
187			}
188		})
189	}
190}
191
192// mr diff on a pruned head says so instead of leaking git's own error.
193func TestMRDiffNamesAPrunedHead(t *testing.T) {
194	st, repo, root, _ := prunedRepo(t)
195	dir := RepoDir(root, repo.OwnerName, repo.Name)
196	gitRunner(t)(dir, "update-ref", "-d", mrHeadRef(1))
197	c, errOut := pruneCtx(st, root, store.User{ID: 1, Username: "alice"})
198	if code := Dispatch(c, []string{"mr", "diff", repo.Path(), "1"}); code != protocol.ExitFailure {
199		t.Fatalf("exit %d, want %d: %s", code, protocol.ExitFailure, errOut.String())
200	}
201	if !strings.Contains(errOut.String(), "no longer in the repository") || strings.Contains(errOut.String(), "exit status") {
202		t.Errorf("stderr %q should say the head is gone and not echo git", errOut.String())
203	}
204}
205
206// The same number twice is one MR: one deletion, one system comment.
207func TestAdminMRPruneDedupesNumbers(t *testing.T) {
208	st, repo, root, _ := prunedRepo(t)
209	c, errOut := pruneCtx(st, root, rootUser(t, st))
210	if code := Dispatch(c, []string{"admin", "mr", "prune", repo.Path(), "1", "1", "--yes"}); code != protocol.ExitOK {
211		t.Fatalf("exit %d: %s", code, errOut.String())
212	}
213	mr, _ := st.MRByNumber(repo.ID, 1)
214	comments, err := st.ListMRComments(mr.ID)
215	if err != nil {
216		t.Fatal(err)
217	}
218	if len(comments) != 1 {
219		t.Errorf("want one system comment, got %d", len(comments))
220	}
221}