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