internal/control/stage3repo_test.go
320 lines · 12689 bytes
15 symbols in this file
1package control
2
3import (
4 "bytes"
5 "crypto/ed25519"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14 "golang.org/x/crypto/ssh"
15)
16
17// dispatchAs runs argv as u with stdin, piped, and returns stdout; a
18// non-zero exit fails t.
19func dispatchAs(t *testing.T, st *store.Store, u store.User, stdin string, argv ...string) string {
20 t.Helper()
21 return dispatchIn(t, st, u, "", stdin, argv...)
22}
23
24// dispatchIn is dispatchAs with repositories under root.
25func dispatchIn(t *testing.T, st *store.Store, u store.User, root, stdin string, argv ...string) string {
26 t.Helper()
27 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
28 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(stdin)}
29 c.Cfg.Server.Root = root
30 // Fixtures write a lot as user 1; the write limiter is global to
31 // the package, so leave its budget to the tests that exercise it.
32 c.Cfg.Limits.WriteRate = -1
33 c.Cfg.Limits.MaxSnippetBytes = 1 << 20
34 c.Cfg.Limits.MaxBlobBytes = 1 << 20
35 if code := Dispatch(c, argv); code != protocol.ExitOK {
36 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
37 }
38 return out.String()
39}
40
41// atTerminal runs argv as u at a 100-column terminal without colour
42// and returns stdout; a non-zero exit fails t.
43func atTerminal(t *testing.T, st *store.Store, u store.User, argv ...string) string {
44 t.Helper()
45 return atTerminalIn(t, st, u, "", argv...)
46}
47
48// atTerminalIn is atTerminal with repositories under root.
49func atTerminalIn(t *testing.T, st *store.Store, u store.User, root string, argv ...string) string {
50 t.Helper()
51 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
52 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(""), Term: Term{Cols: 100}}
53 c.Cfg.Server.Root = root
54 // Fixtures write a lot as user 1; the write limiter is global to
55 // the package, so leave its budget to the tests that exercise it.
56 c.Cfg.Limits.WriteRate = -1
57 c.Cfg.Limits.MaxSnippetBytes = 1 << 20
58 c.Cfg.Limits.MaxBlobBytes = 1 << 20
59 if code := Dispatch(c, argv); code != protocol.ExitOK {
60 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
61 }
62 return out.String()
63}
64
65// checkLegend fails t for any suggested command in a rendered screen
66// (a line starting "gitbay ", or after "+n more ") that the registry
67// would not dispatch with the flags it names.
68func checkLegend(t *testing.T, out string) {
69 t.Helper()
70 var s screen
71 for _, line := range strings.Split(out, "\n") {
72 if i := strings.Index(line, "more gitbay "); i >= 0 {
73 line = line[i+len("more "):]
74 }
75 for _, cmd := range strings.Split(line, " ") {
76 cmd = strings.TrimSpace(cmd)
77 if !strings.HasPrefix(cmd, "gitbay ") {
78 continue
79 }
80 argv, err := protocol.Tokenize(strings.TrimPrefix(cmd, "gitbay "))
81 if err != nil {
82 t.Errorf("%q: %v", cmd, err)
83 continue
84 }
85 s.actions = append(s.actions, action{"", argv})
86 }
87 }
88 if len(s.actions) == 0 {
89 t.Errorf("no suggested commands in:\n%s", out)
90 }
91 checkActions(t, s)
92}
93
94// newPubKey is an ed25519 public key in authorized_keys form, the same
95// on every run so its fingerprint can be pinned.
96func newPubKey(t *testing.T) string {
97 t.Helper()
98 pub := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize)).Public().(ed25519.PublicKey)
99 sp, err := ssh.NewPublicKey(pub)
100 if err != nil {
101 t.Fatal(err)
102 }
103 return string(ssh.MarshalAuthorizedKey(sp))
104}
105
106// repoListsFixture is a repository with something in every list task 1
107// migrates.
108func repoListsFixture(t *testing.T) (*store.Store, store.Repo, store.User) {
109 t.Helper()
110 st, repo, uid := newQueueTestRepo(t)
111 owner := store.User{ID: uid, Username: "alice", IsAdmin: true}
112 p := repo.Path()
113 dispatchAs(t, st, owner, "", "repo", "topics", "add", p, "cli", "forge")
114 dispatchAs(t, st, owner, "s3cret\n", "repo", "secret", "set", p, "DEPLOY_TOKEN")
115 dispatchAs(t, st, owner, testRunnerPub, "repo", "deploy-key", "add", p)
116 dispatchAs(t, st, owner, "", "repo", "domain", "add", p, "docs.example.test")
117 dispatchAs(t, st, owner, "", "repo", "bookmark", p)
118 // mirror add resolves the host, so the fixture goes to the store.
119 if _, err := st.AddMirror(repo.ID, "push", "https://mirror.example.test/app.git", "", "tok"); err != nil {
120 t.Fatal(err)
121 }
122 dispatchAs(t, st, owner, newPubKey(t), "repo", "runner", "add", p)
123 return st, repo, owner
124}
125
126func TestRepoListsPlainPinned(t *testing.T) {
127 st, repo, owner := repoListsFixture(t)
128 p := repo.Path()
129 for name, argv := range map[string][]string{
130 "repo-topics": {"repo", "topics", p},
131 "repo-secret-list": {"repo", "secret", "list", p},
132 "repo-deploy-key-list": {"repo", "deploy-key", "list", p},
133 "repo-domain-list": {"repo", "domain", "list", p},
134 "repo-access-list": {"repo", "access", "list", p},
135 "repo-bookmarks": {"repo", "bookmarks"},
136 "repo-search": {"repo", "search", "app"},
137 "repo-list": {"repo", "list"},
138 "repo-mirror-list": {"repo", "mirror", "list", p},
139 "repo-runner-list": {"repo", "runner", "list", p},
140 } {
141 pinPlain(t, name, dispatchAs(t, st, owner, "", argv...))
142 }
143 pinPlain(t, "repo-topics-add", dispatchAs(t, st, owner, "", "repo", "topics", "add", p, "git"))
144}
145
146func TestRepoListScreens(t *testing.T) {
147 st, repo, owner := repoListsFixture(t)
148 p := repo.Path()
149 for _, tc := range []struct {
150 argv []string
151 want []string
152 }{
153 {[]string{"repo", "topics", p}, []string{"Topics (2)\ncli\nforge\n"}},
154 {[]string{"repo", "topics", "add", p, "git"}, []string{"Topics (3)\n"}},
155 {[]string{"repo", "secret", "list", p}, []string{"Build secrets (1)\nDEPLOY_TOKEN\n"}},
156 {[]string{"repo", "deploy-key", "list", p}, []string{"Deploy keys (1)\n", " ro runner@test ssh-ed25519 · used never"}},
157 {[]string{"repo", "domain", "list", p}, []string{"Pages domains (1)\n", "◐ docs.example.test pending"}},
158 {[]string{"repo", "access", "list", p}, []string{"Access (1)\nalice admin via owner\n"}},
159 {[]string{"repo", "mirror", "list", p}, []string{"Mirrors (1)\n1 ◐ https://mirror.example.test/app.git push · never synced"}},
160 {[]string{"repo", "runner", "list", p}, []string{"Runners (1)\n", " alice ssh-ed25519 · never seen"}},
161 {[]string{"repo", "bookmarks"}, []string{"Bookmarks (1)\nalice/app public 1 bookmark\n"}},
162 {[]string{"repo", "search", "app"}, []string{"Repositories matching \"app\" (1)\nalice/app public cli, forge"}},
163 {[]string{"repo", "list"}, []string{"Repositories (1)\nalice/app public\n"}},
164 } {
165 out := atTerminal(t, st, owner, tc.argv...)
166 for _, w := range tc.want {
167 if !strings.Contains(out, w) {
168 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
169 }
170 }
171 if strings.Contains(out, "s3cret") || strings.Contains(out, "tok\n") {
172 t.Errorf("%v: secret on screen:\n%s", tc.argv, out)
173 }
174 checkLegend(t, out)
175 }
176}
177
178// browseFixture is a repository on disk under root: two commits on main
179// (the second touching docs/), a branch, and a tag, with fixed dates so
180// the shas are the same on every run.
181func browseFixture(t *testing.T) (*store.Store, store.Repo, store.User, string) {
182 t.Helper()
183 st, repo, uid := newQueueTestRepo(t)
184 root := t.TempDir()
185 env := append(gitTestEnv(), "GIT_AUTHOR_DATE=2026-09-01T10:00:00Z", "GIT_COMMITTER_DATE=2026-09-01T10:00:00Z")
186 git := func(dir string, args ...string) {
187 t.Helper()
188 cmd := exec.Command("git", args...)
189 cmd.Dir, cmd.Env = dir, env
190 if out, err := cmd.CombinedOutput(); err != nil {
191 t.Fatalf("git %v: %v\n%s", args, err, out)
192 }
193 }
194 src := filepath.Join(root, "src")
195 if err := os.MkdirAll(filepath.Join(src, "docs"), 0o755); err != nil {
196 t.Fatal(err)
197 }
198 os.WriteFile(filepath.Join(src, "README.md"), []byte("# app\n"), 0o644)
199 git(root, "init", "-q", "-b", "main", "src")
200 git(src, "add", ".")
201 git(src, "commit", "-q", "-m", "first")
202 os.WriteFile(filepath.Join(src, "docs", "guide.md"), []byte("# guide\n"), 0o644)
203 git(src, "add", ".")
204 git(src, "commit", "-q", "-m", "docs: guide")
205 git(src, "tag", "v1.0.0")
206 git(src, "branch", "feature")
207 dir := RepoDir(root, repo.OwnerName, repo.Name)
208 os.MkdirAll(filepath.Dir(dir), 0o755)
209 git(root, "clone", "-q", "--bare", src, dir)
210 return st, repo, store.User{ID: uid, Username: "alice"}, root
211}
212
213func TestRepoBrowsePlainPinned(t *testing.T) {
214 st, repo, u, root := browseFixture(t)
215 p := repo.Path()
216 for name, argv := range map[string][]string{
217 "repo-log": {"repo", "log", p},
218 "repo-log-path": {"repo", "log", p, "--path", "docs/guide.md"},
219 "repo-tree": {"repo", "tree", p},
220 "repo-tree-docs": {"repo", "tree", p, "docs"},
221 "repo-refs": {"repo", "refs", p},
222 } {
223 pinPlain(t, name, dispatchIn(t, st, u, root, "", argv...))
224 }
225 sst, srepo, alice, _ := symbolsFixture(t)
226 pinPlain(t, "repo-symbols", dispatchAs(t, sst, alice, "", "repo", "symbols", srepo.Path(), "Pars"))
227}
228
229func TestRepoBrowseScreens(t *testing.T) {
230 st, repo, u, root := browseFixture(t)
231 p := repo.Path()
232 for _, tc := range []struct {
233 argv []string
234 want []string
235 }{
236 {[]string{"repo", "log", p}, []string{"Commits on main (2)\n", " docs: guide t · "}},
237 {[]string{"repo", "log", p, "--path", "docs/guide.md"}, []string{"Commits on main touching docs/guide.md (1)\n"}},
238 {[]string{"repo", "tree", p}, []string{"alice/app at main (2)\n", "docs/", "README.md"}},
239 {[]string{"repo", "tree", p, "docs"}, []string{"alice/app/docs at main (1)\n", "guide.md"}},
240 {[]string{"repo", "refs", p}, []string{"Branches (2)\nmain", "default", "Tags (1)\nv1.0.0"}},
241 } {
242 out := atTerminalIn(t, st, u, root, tc.argv...)
243 for _, w := range tc.want {
244 if !strings.Contains(out, w) {
245 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
246 }
247 }
248 checkLegend(t, out)
249 }
250 sst, srepo, alice, _ := symbolsFixture(t)
251 out := atTerminal(t, sst, alice, "repo", "symbols", srepo.Path(), "Pars")
252 if !strings.Contains(out, "Symbols matching \"Pars\" (") || !strings.Contains(out, "Parse function p.go:1") {
253 t.Errorf("symbols:\n%s", out)
254 }
255 checkLegend(t, out)
256}
257
258func TestRepoShowsPlainPinned(t *testing.T) {
259 st, repo, u, root := browseFixture(t)
260 p := repo.Path()
261 pinPlain(t, "repo-settings-show", dispatchIn(t, st, u, root, "", "repo", "settings", "show", p))
262 pinPlain(t, "repo-deps-status-off", dispatchIn(t, st, u, root, "", "repo", "deps", "status", p))
263 pinPlain(t, "repo-commit", dispatchIn(t, st, u, root, "", "repo", "commit", p, "aee4475ad9"))
264 dispatchIn(t, st, u, root, "", "repo", "deps", "enable", p)
265 pinPlain(t, "repo-deps-status", dispatchIn(t, st, u, root, "", "repo", "deps", "status", p))
266}
267
268func TestRepoShowScreens(t *testing.T) {
269 st, repo, u, root := browseFixture(t)
270 p := repo.Path()
271 for _, tc := range []struct {
272 argv []string
273 want []string
274 }{
275 {[]string{"repo", "settings", "show", p}, []string{"Repo:", "alice/app", "Require MR:", "off", "Approvals:"}},
276 {[]string{"repo", "deps", "status", p}, []string{"Checks: off\n", "gitbay repo deps enable alice/app"}},
277 {[]string{"repo", "commit", p, "aee4475ad9"}, []string{"Commit:", "aee4475ad9 docs: guide", "Author:", "t <t@example.test>", "gitbay repo log alice/app --ref aee4475ad9", "+# guide"}},
278 } {
279 out := atTerminalIn(t, st, u, root, tc.argv...)
280 for _, w := range tc.want {
281 if !strings.Contains(out, w) {
282 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
283 }
284 }
285 checkLegend(t, out)
286 }
287 dispatchIn(t, st, u, root, "", "repo", "deps", "enable", p)
288 out := atTerminalIn(t, st, u, root, "repo", "deps", "status", p)
289 if !strings.Contains(out, "Checks:") || !strings.Contains(out, "gitbay repo deps disable alice/app") {
290 t.Errorf("deps on:\n%s", out)
291 }
292 checkLegend(t, out)
293 if i, j := strings.Index(out, "gitbay repo deps"), strings.Index(out, "Checks:"); i < j {
294 t.Errorf("legend before the header:\n%s", out)
295 }
296}
297
298// At 60 columns no screen line is wider than the terminal, apart from a
299// suggested command: e2e's readonly test holds every read command to it.
300func TestRepoScreensFitSixtyColumns(t *testing.T) {
301 st, repo, owner := repoListsFixture(t)
302 p := repo.Path()
303 for _, argv := range [][]string{
304 {"repo", "deploy-key", "list", p},
305 {"repo", "runner", "list", p},
306 {"repo", "mirror", "list", p},
307 } {
308 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
309 c := &Ctx{User: owner, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Term: Term{Cols: 60}}
310 c.Cfg.Limits.WriteRate = -1
311 if code := Dispatch(c, argv); code != protocol.ExitOK {
312 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
313 }
314 for _, line := range strings.Split(out.String(), "\n") {
315 if cells(line) > 60 && !strings.HasPrefix(line, "gitbay ") {
316 t.Errorf("%v: %d cells: %q", argv, cells(line), line)
317 }
318 }
319 }
320}