e2e/git_test.go
160 lines · 5387 bytes
5 symbols in this file
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 t.Parallel()
59 inst := startInstance(t)
60
61 aliceKey := inst.newKey(t, "alice")
62 bobKey := inst.newKey(t, "bob")
63 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
64 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
65
66 // Alice creates a private repo over bare ssh.
67 _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj", "--private")
68 if code != 0 {
69 t.Fatalf("repo create: exit %d, %s", code, errOut)
70 }
71
72 // Alice clones (empty), commits, pushes.
73 work := t.TempDir()
74 aliceEnv := inst.gitEnv(aliceKey)
75 mustGit(t, work, aliceEnv, "clone", inst.sshURL("alice/proj"), "proj")
76 dir := filepath.Join(work, "proj")
77 if err := os.WriteFile(filepath.Join(dir, "README"), []byte("hello\n"), 0o644); err != nil {
78 t.Fatal(err)
79 }
80 mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
81 mustGit(t, dir, aliceEnv, "add", "README")
82 mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "init")
83 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
84
85 // Bob is denied clone of the private repo, indistinguishable from
86 // nonexistence.
87 bobEnv := inst.gitEnv(bobKey)
88 out, code := gitRun(t, t.TempDir(), bobEnv, "clone", inst.sshURL("alice/proj"), "proj")
89 if code == 0 {
90 t.Fatal("bob cloned a private repo without access")
91 }
92 if !strings.Contains(out, "repository not found") {
93 t.Fatalf("denial should read as not-found, got:\n%s", out)
94 }
95
96 // Alice grants bob read; clone succeeds; push is denied.
97 _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/proj", "bob", "read")
98 if code != 0 {
99 t.Fatalf("access grant: exit %d, %s", code, errOut)
100 }
101 bobWork := t.TempDir()
102 mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/proj"), "proj")
103 bobDir := filepath.Join(bobWork, "proj")
104 if err := os.WriteFile(filepath.Join(bobDir, "x"), []byte("x\n"), 0o644); err != nil {
105 t.Fatal(err)
106 }
107 mustGit(t, bobDir, bobEnv, "add", "x")
108 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "bob")
109 out, code = gitRun(t, bobDir, bobEnv, "push", "origin", "main")
110 if code == 0 {
111 t.Fatal("bob pushed with read-only access")
112 }
113 if !strings.Contains(out, "write access to alice/proj denied") {
114 t.Fatalf("push denial message:\n%s", out)
115 }
116
117 // Alice protects main: force-push and deletion are refused by the hook,
118 // normal pushes still work.
119 _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "alice/proj", "main")
120 if code != 0 {
121 t.Fatalf("protect: exit %d, %s", code, errOut)
122 }
123
124 mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "second")
125 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
126
127 mustGit(t, dir, aliceEnv, "reset", "-q", "--hard", "HEAD~1")
128 mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "rewritten")
129 out, code = gitRun(t, dir, aliceEnv, "push", "--force", "origin", "main")
130 if code == 0 {
131 t.Fatal("force-push to protected branch succeeded")
132 }
133 if !strings.Contains(out, "force-push refused") {
134 t.Fatalf("force-push denial message:\n%s", out)
135 }
136
137 out, code = gitRun(t, dir, aliceEnv, "push", "origin", ":main")
138 if code == 0 {
139 t.Fatal("deletion of protected branch succeeded")
140 }
141 if !strings.Contains(out, "deletion refused") {
142 t.Fatalf("deletion denial message:\n%s", out)
143 }
144
145 // refs/merge-requests/* is unpushable even by the owner.
146 out, code = gitRun(t, dir, aliceEnv, "push", "origin", "HEAD:refs/merge-requests/1/head")
147 if code == 0 {
148 t.Fatal("client pushed into refs/merge-requests/*")
149 }
150 if !strings.Contains(out, "server-owned") {
151 t.Fatalf("mr-ref denial message:\n%s", out)
152 }
153
154 // Unprotect: force-push now goes through.
155 _, _, code = inst.ssh(t, aliceKey, "", "repo", "settings", "unprotect", "alice/proj", "main")
156 if code != 0 {
157 t.Fatal("unprotect failed")
158 }
159 mustGit(t, dir, aliceEnv, "push", "-q", "--force", "origin", "main")
160}