krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
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}