internal/control/stage3repo_test.go

v1.43.0
gitbay/internal/control/stage3repo_test.go history · blame · raw

320 lines · 12689 bytes

  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}