krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

repo-descriptions: e2e/git_test.go · raw

  1package e2e
  2
  3import (
  4	"fmt"
  5	"os"
  6	"os/exec"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10)
 11
 12// gitEnv returns the environment for running the git client against the
 13// instance with the given key.
 14func (i *instance) gitEnv(key string) []string {
 15	sshCmd := fmt.Sprintf(
 16		"ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
 17		key, filepath.Join(i.sshDir, "known_hosts"))
 18	return append(os.Environ(),
 19		"GIT_SSH_COMMAND="+sshCmd,
 20		// Isolate from the developer's own git config (signing, helpers).
 21		"GIT_CONFIG_NOSYSTEM=1",
 22		"GIT_CONFIG_GLOBAL=/dev/null",
 23		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
 24		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
 25	)
 26}
 27
 28func (i *instance) sshURL(repo string) string {
 29	return fmt.Sprintf("ssh://git@127.0.0.1:%d/%s.git", i.port, repo)
 30}
 31
 32// git runs a git command; returns combined output and exit code.
 33func gitRun(t *testing.T, dir string, env []string, args ...string) (string, int) {
 34	t.Helper()
 35	cmd := exec.Command("git", args...)
 36	cmd.Dir = dir
 37	cmd.Env = env
 38	out, err := cmd.CombinedOutput()
 39	code := 0
 40	if ee, ok := err.(*exec.ExitError); ok {
 41		code = ee.ExitCode()
 42	} else if err != nil {
 43		t.Fatalf("git %v: %v", args, err)
 44	}
 45	return string(out), code
 46}
 47
 48func mustGit(t *testing.T, dir string, env []string, args ...string) string {
 49	t.Helper()
 50	out, code := gitRun(t, dir, env, args...)
 51	if code != 0 {
 52		t.Fatalf("git %v failed (%d):\n%s", args, code, out)
 53	}
 54	return out
 55}
 56
 57func TestGitOverSSH(t *testing.T) {
 58	inst := startInstance(t)
 59
 60	aliceKey := inst.newKey(t, "alice")
 61	bobKey := inst.newKey(t, "bob")
 62	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
 63	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
 64
 65	// Alice creates a private repo over bare ssh.
 66	_, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj", "--private")
 67	if code != 0 {
 68		t.Fatalf("repo create: exit %d, %s", code, errOut)
 69	}
 70
 71	// Alice clones (empty), commits, pushes.
 72	work := t.TempDir()
 73	aliceEnv := inst.gitEnv(aliceKey)
 74	mustGit(t, work, aliceEnv, "clone", inst.sshURL("alice/proj"), "proj")
 75	dir := filepath.Join(work, "proj")
 76	if err := os.WriteFile(filepath.Join(dir, "README"), []byte("hello\n"), 0o644); err != nil {
 77		t.Fatal(err)
 78	}
 79	mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
 80	mustGit(t, dir, aliceEnv, "add", "README")
 81	mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "init")
 82	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
 83
 84	// Bob is denied clone of the private repo, indistinguishable from
 85	// nonexistence.
 86	bobEnv := inst.gitEnv(bobKey)
 87	out, code := gitRun(t, t.TempDir(), bobEnv, "clone", inst.sshURL("alice/proj"), "proj")
 88	if code == 0 {
 89		t.Fatal("bob cloned a private repo without access")
 90	}
 91	if !strings.Contains(out, "repository not found") {
 92		t.Fatalf("denial should read as not-found, got:\n%s", out)
 93	}
 94
 95	// Alice grants bob read; clone succeeds; push is denied.
 96	_, errOut, code = inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/proj", "bob", "read")
 97	if code != 0 {
 98		t.Fatalf("access grant: exit %d, %s", code, errOut)
 99	}
100	bobWork := t.TempDir()
101	mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/proj"), "proj")
102	bobDir := filepath.Join(bobWork, "proj")
103	if err := os.WriteFile(filepath.Join(bobDir, "x"), []byte("x\n"), 0o644); err != nil {
104		t.Fatal(err)
105	}
106	mustGit(t, bobDir, bobEnv, "add", "x")
107	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "bob")
108	out, code = gitRun(t, bobDir, bobEnv, "push", "origin", "main")
109	if code == 0 {
110		t.Fatal("bob pushed with read-only access")
111	}
112	if !strings.Contains(out, "write access to alice/proj denied") {
113		t.Fatalf("push denial message:\n%s", out)
114	}
115
116	// Alice protects main: force-push and deletion are refused by the hook,
117	// normal pushes still work.
118	_, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "alice/proj", "main")
119	if code != 0 {
120		t.Fatalf("protect: exit %d, %s", code, errOut)
121	}
122
123	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "second")
124	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
125
126	mustGit(t, dir, aliceEnv, "reset", "-q", "--hard", "HEAD~1")
127	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "rewritten")
128	out, code = gitRun(t, dir, aliceEnv, "push", "--force", "origin", "main")
129	if code == 0 {
130		t.Fatal("force-push to protected branch succeeded")
131	}
132	if !strings.Contains(out, "force-push refused") {
133		t.Fatalf("force-push denial message:\n%s", out)
134	}
135
136	out, code = gitRun(t, dir, aliceEnv, "push", "origin", ":main")
137	if code == 0 {
138		t.Fatal("deletion of protected branch succeeded")
139	}
140	if !strings.Contains(out, "deletion refused") {
141		t.Fatalf("deletion denial message:\n%s", out)
142	}
143
144	// refs/merge-requests/* is unpushable even by the owner.
145	out, code = gitRun(t, dir, aliceEnv, "push", "origin", "HEAD:refs/merge-requests/1/head")
146	if code == 0 {
147		t.Fatal("client pushed into refs/merge-requests/*")
148	}
149	if !strings.Contains(out, "server-owned") {
150		t.Fatalf("mr-ref denial message:\n%s", out)
151	}
152
153	// Unprotect: force-push now goes through.
154	_, _, code = inst.ssh(t, aliceKey, "", "repo", "settings", "unprotect", "alice/proj", "main")
155	if code != 0 {
156		t.Fatal("unprotect failed")
157	}
158	mustGit(t, dir, aliceEnv, "push", "-q", "--force", "origin", "main")
159}