e2e/mirror_test.go
201 lines · 8118 bytes
4 symbols in this file
1package e2e
2
3import (
4 "encoding/json"
5 "net/http/cgi"
6 "net/http/httptest"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13)
14
15// gitHTTPRemote serves a bare repository over smart HTTP (push enabled),
16// standing in for GitHub in mirror tests.
17func gitHTTPRemote(t *testing.T) (url, bareDir string) {
18 t.Helper()
19 parent := t.TempDir()
20 bareDir = filepath.Join(parent, "remote.git")
21 for _, args := range [][]string{
22 {"init", "--bare", "--initial-branch=main", bareDir},
23 {"-C", bareDir, "config", "http.receivepack", "true"},
24 } {
25 if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
26 t.Fatalf("git %v: %v\n%s", args, err, out)
27 }
28 }
29 execPath, err := exec.Command("git", "--exec-path").Output()
30 if err != nil {
31 t.Fatal(err)
32 }
33 h := &cgi.Handler{
34 Path: filepath.Join(strings.TrimSpace(string(execPath)), "git-http-backend"),
35 Env: []string{"GIT_PROJECT_ROOT=" + parent, "GIT_HTTP_EXPORT_ALL=1"},
36 }
37 srv := httptest.NewServer(h)
38 t.Cleanup(srv.Close)
39 return srv.URL + "/remote.git", bareDir
40}
41
42func waitFor(t *testing.T, what string, cond func() bool) {
43 t.Helper()
44 deadline := time.Now().Add(15 * time.Second)
45 for time.Now().Before(deadline) {
46 if cond() {
47 return
48 }
49 time.Sleep(150 * time.Millisecond)
50 }
51 t.Fatalf("timed out waiting for %s", what)
52}
53
54func TestMirrors(t *testing.T) {
55 t.Setenv("GITBAY_MIRROR_TICK", "200ms")
56 inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n[web]\nmode = \"accounts\"\n")
57 aliceKey := inst.newKey(t, "alice")
58 bobKey := inst.newKey(t, "bob")
59 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
60 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
61
62 // ---- push mirror: local pushes propagate to the remote.
63 remoteURL, remoteBare := gitHTTPRemote(t)
64 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
65 t.Fatalf("repo create: %s", errOut)
66 }
67 if _, _, code := inst.ssh(t, bobKey, "", "repo", "mirror", "add", "alice/app", remoteURL, "--direction", "push"); code != 4 {
68 t.Fatal("non-admin added a mirror")
69 }
70 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "add", "alice/app", remoteURL, "--direction", "push"); code != 0 {
71 t.Fatalf("mirror add: %s", errOut)
72 }
73
74 work := t.TempDir()
75 env := inst.gitEnv(aliceKey)
76 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
77 dir := filepath.Join(work, "w")
78 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
79 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
80 mustGit(t, dir, env, "add", ".")
81 mustGit(t, dir, env, "commit", "-q", "-m", "base")
82 mustGit(t, dir, env, "push", "-q", "origin", "main")
83 head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
84
85 waitFor(t, "push mirror sync", func() bool {
86 out, _ := exec.Command("git", "-C", remoteBare, "rev-parse", "refs/heads/main").Output()
87 return strings.TrimSpace(string(out)) == head
88 })
89 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/app", "--json")
90 if !strings.Contains(out, `"last_sync":"`) || strings.Contains(out, "token") ||
91 strings.Contains(out, `"last_error":"`) {
92 t.Fatalf("mirror list after sync: %s", out)
93 }
94
95 // ---- status surfacing: repo show carries mirrors for admins only.
96 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json")
97 if !strings.Contains(out, `"mirrors":[`) || !strings.Contains(out, `"last_sync":"`) ||
98 strings.Contains(out, "token") {
99 t.Fatalf("repo show missing mirror status: %s", out)
100 }
101 out, _, _ = inst.ssh(t, bobKey, "", "repo", "show", "alice/app", "--json")
102 if strings.Contains(out, `"mirrors"`) {
103 t.Fatalf("repo show leaked mirrors to non-admin: %s", out)
104 }
105
106 // The web repo page shows the mirror line to the admin, not to visitors.
107 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
108 if code != 0 {
109 t.Fatalf("web login: %s", errOut)
110 }
111 var loginEnv struct {
112 Data struct {
113 URL string `json:"url"`
114 } `json:"data"`
115 }
116 json.Unmarshal([]byte(out), &loginEnv)
117 loginPath := loginEnv.Data.URL[strings.Index(loginEnv.Data.URL, "/login"):]
118 browser := newBrowser(t)
119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
120 t.Fatalf("login: %d", status)
121 }
122 if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "mirrors to <a href=\""+remoteURL) {
123 t.Fatalf("admin repo page missing mirror line:\n%s", body)
124 }
125 if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "mirrors to") {
126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body)
127 }
128
129 // ---- pull mirror: local repo follows the remote and refuses pushes.
130 srcURL, srcBare := gitHTTPRemote(t)
131 seed := t.TempDir()
132 mustGit(t, seed, env, "clone", "-q", srcBare, "s")
133 sdir := filepath.Join(seed, "s")
134 os.WriteFile(filepath.Join(sdir, "up.txt"), []byte("upstream\n"), 0o644)
135 mustGit(t, sdir, env, "checkout", "-q", "-b", "main")
136 mustGit(t, sdir, env, "add", ".")
137 mustGit(t, sdir, env, "commit", "-q", "-m", "upstream commit")
138 mustGit(t, sdir, env, "push", "-q", "origin", "main")
139 upstreamHead := strings.TrimSpace(mustGit(t, sdir, env, "rev-parse", "HEAD"))
140
141 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/follow"); code != 0 {
142 t.Fatal("repo create failed")
143 }
144 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "add", "alice/follow", srcURL, "--direction", "pull"); code != 0 {
145 t.Fatalf("pull mirror add: %s", errOut)
146 }
147 waitFor(t, "pull mirror sync", func() bool {
148 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/follow", "--json")
149 return strings.Contains(out, upstreamHead)
150 })
151 // Local pushes are refused while the pull mirror exists.
152 work2 := t.TempDir()
153 mustGit(t, work2, env, "clone", "-q", inst.sshURL("alice/follow"), "f")
154 fdir := filepath.Join(work2, "f")
155 os.WriteFile(filepath.Join(fdir, "no.txt"), []byte("n\n"), 0o644)
156 mustGit(t, fdir, env, "add", ".")
157 mustGit(t, fdir, env, "commit", "-q", "-m", "local change")
158 if out, code := gitRun(t, fdir, env, "push", "origin", "HEAD:main"); code == 0 || !strings.Contains(out, "pull mirror") {
159 t.Fatalf("push to pull mirror: exit %d\n%s", code, out)
160 }
161 // Removing the mirror restores pushes.
162 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/follow", "--json")
163 id := out[strings.Index(out, `"id":`)+5:]
164 id = id[:strings.IndexAny(id, ",}")]
165 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "remove", "alice/follow", strings.TrimSpace(id)); code != 0 {
166 t.Fatal("mirror remove failed")
167 }
168 mustGit(t, fdir, env, "push", "-q", "origin", "HEAD:main")
169
170 // ---- failure visibility: a dead remote records an error.
171 deadURL := remoteURL + "-gone"
172 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "add", "alice/follow", deadURL, "--direction", "push"); code != 0 {
173 t.Fatal("dead mirror add failed")
174 }
175 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "sync", "alice/follow"); code != 0 {
176 t.Fatal("mirror sync failed")
177 }
178 waitFor(t, "failure recorded", func() bool {
179 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/follow", "--json")
180 return strings.Contains(out, `"last_error":"git push`)
181 })
182 // The failure is visible on the admin's web repo page.
183 if _, body := browserGet(t, browser, inst.base()+"/alice/follow"); !strings.Contains(body, "sync error:") {
184 t.Fatalf("admin repo page missing sync error:\n%s", body)
185 }
186}
187
188func TestMirrorSSRFGuard(t *testing.T) {
189 t.Parallel()
190 inst := startInstance(t) // default posture: allow_local off
191 aliceKey := inst.newKey(t, "alice")
192 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
193 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
194 t.Fatal("repo create failed")
195 }
196 _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "add", "alice/app",
197 "http://127.0.0.1:9999/x.git", "--direction", "push")
198 if code != 2 || !strings.Contains(errOut, "SSRF") {
199 t.Fatalf("local mirror allowed: exit %d, %s", code, errOut)
200 }
201}