internal/control/mrprune_test.go
221 lines · 7858 bytes
11 symbols in this file
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}