internal/mirror/mirror_test.go
259 lines · 8707 bytes
1package mirror
2
3import (
4 "context"
5 "net"
6 "net/http/cgi"
7 "net/http/httptest"
8 "net/url"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "slices"
13 "strings"
14 "testing"
15
16 "gitbay.org/gitbay/internal/config"
17 "gitbay.org/gitbay/internal/control"
18 "gitbay.org/gitbay/internal/store"
19)
20
21func git(t *testing.T, dir string, args ...string) string {
22 t.Helper()
23 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
24 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+t.TempDir(),
25 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
26 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
27 out, err := cmd.CombinedOutput()
28 if err != nil {
29 t.Fatalf("git %v: %v\n%s", args, err, out)
30 }
31 return strings.TrimSpace(string(out))
32}
33
34// upstream serves a bare repository with one commit on main over smart
35// HTTP and returns its URL and that commit.
36func upstream(t *testing.T) (string, string) {
37 t.Helper()
38 parent := t.TempDir()
39 bare := filepath.Join(parent, "remote.git")
40 work := filepath.Join(parent, "work")
41 git(t, parent, "init", "-q", "--bare", "--initial-branch=main", bare)
42 git(t, parent, "init", "-q", "--initial-branch=main", work)
43 git(t, work, "commit", "-q", "--allow-empty", "-m", "one")
44 git(t, work, "push", "-q", bare, "main")
45 sha := git(t, work, "rev-parse", "HEAD")
46 execPath := git(t, parent, "--exec-path")
47 srv := httptest.NewServer(&cgi.Handler{
48 Path: filepath.Join(execPath, "git-http-backend"),
49 Env: []string{"GIT_PROJECT_ROOT=" + parent, "GIT_HTTP_EXPORT_ALL=1"},
50 })
51 t.Cleanup(srv.Close)
52 return srv.URL + "/remote.git", sha
53}
54
55// local returns a store with alice/app, its bare repository under root,
56// and the pull mirror row for url.
57func local(t *testing.T, root, mirrorURL string) (*store.Store, store.Mirror, string) {
58 t.Helper()
59 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
60 if err != nil {
61 t.Fatal(err)
62 }
63 t.Cleanup(func() { st.Close() })
64 if err := st.MigrateUp(); err != nil {
65 t.Fatal(err)
66 }
67 uid, err := st.CreateUser("alice", false)
68 if err != nil {
69 t.Fatal(err)
70 }
71 repoID, err := st.CreateRepo("user", uid, "app", "public")
72 if err != nil {
73 t.Fatal(err)
74 }
75 dir := control.RepoDir(root, "alice", "app")
76 os.MkdirAll(filepath.Dir(dir), 0o755)
77 git(t, root, "init", "-q", "--bare", dir)
78 if _, err := st.AddMirror(repoID, "pull", mirrorURL, "", ""); err != nil {
79 t.Fatal(err)
80 }
81 due, err := st.DueMirrors(900)
82 if err != nil || len(due) != 1 {
83 t.Fatalf("due mirrors: %v %v", due, err)
84 }
85 return st, due[0], dir
86}
87
88// mirror.test does not resolve; the fetch works only because git was
89// pinned to the address the worker looked up and checked.
90func TestSyncConnectsToTheCheckedAddress(t *testing.T) {
91 remote, sha := upstream(t)
92 u, _ := url.Parse(remote)
93 root := t.TempDir()
94 st, m, dir := local(t, root, "http://mirror.test:"+u.Port()+"/remote.git")
95 var cfg config.Config
96 cfg.Server.Root = root
97 cfg.Webhooks.AllowLocal = true
98 var asked []string
99 w := &Worker{St: st, Cfg: cfg, Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
100 asked = append(asked, host)
101 return []net.IP{net.ParseIP("127.0.0.1")}, nil
102 }}
103 if err := w.sync(m); err != nil {
104 t.Fatal(err)
105 }
106 if got := git(t, dir, "rev-parse", "refs/heads/main"); got != sha {
107 t.Fatalf("main = %s, want %s", got, sha)
108 }
109 if !slices.Equal(asked, []string{"mirror.test"}) {
110 t.Fatalf("looked up %v", asked)
111 }
112}
113
114// The server account's own gitconfig cannot route git around the pin:
115// a proxy and a URL rewrite in HOME's config are both ignored.
116func TestSyncIgnoresGlobalGitConfig(t *testing.T) {
117 remote, sha := upstream(t)
118 u, _ := url.Parse(remote)
119 root := t.TempDir()
120 st, m, dir := local(t, root, "http://mirror.test:"+u.Port()+"/remote.git")
121 conf := "[http]\n\tproxy = http://127.0.0.1:9\n[url \"http://elsewhere.test/\"]\n\tinsteadOf = http://mirror.test:" + u.Port() + "/\n"
122 if err := os.WriteFile(filepath.Join(root, ".gitconfig"), []byte(conf), 0o644); err != nil {
123 t.Fatal(err)
124 }
125 var cfg config.Config
126 cfg.Server.Root = root
127 cfg.Webhooks.AllowLocal = true
128 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
129 return []net.IP{net.ParseIP("127.0.0.1")}, nil
130 }}
131 if err := w.sync(m); err != nil {
132 t.Fatal(err)
133 }
134 if got := git(t, dir, "rev-parse", "refs/heads/main"); got != sha {
135 t.Fatalf("main = %s, want %s", got, sha)
136 }
137}
138
139// A git too old for http.curloptResolve would ignore the pin; the
140// sweep refuses to sync and says why on every due mirror.
141func TestSweepRefusesWithAnOldGit(t *testing.T) {
142 root := t.TempDir()
143 st, m, _ := local(t, root, "https://mirror.test/x.git")
144 var cfg config.Config
145 cfg.Server.Root = root
146 cfg.Mirrors.PullIntervalMinutes = 15
147 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
148 t.Fatal("looked up a host with an old git")
149 return nil, nil
150 }}
151 w.gitErr = gitVersionOK("git version 2.36.1")
152 w.sweep()
153 ms, err := st.ListMirrors(m.RepoID)
154 if err != nil || len(ms) != 1 {
155 t.Fatalf("mirrors: %v %v", ms, err)
156 }
157 if !strings.Contains(ms[0].LastError, "2.37") {
158 t.Fatalf("last error = %q", ms[0].LastError)
159 }
160}
161
162func TestGitVersionOK(t *testing.T) {
163 for _, s := range []string{"git version 2.37.0", "git version 2.47.3", "git version 2.39.5 (Apple Git-154)",
164 "git version 2.45.2.windows.1", "git version 3.0.0\n"} {
165 if err := gitVersionOK(s); err != nil {
166 t.Errorf("%q: %v", s, err)
167 }
168 }
169 for _, s := range []string{"git version 2.36.9", "git version 1.99.0", "git version 2", "nonsense", ""} {
170 if err := gitVersionOK(s); err == nil {
171 t.Errorf("%q accepted", s)
172 }
173 }
174}
175
176// The URL passed the check when it was saved; the answer at sync time
177// is what counts.
178func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) {
179 root := t.TempDir()
180 st, m, _ := local(t, root, "https://mirror.test/x.git")
181 var cfg config.Config
182 cfg.Server.Root = root
183 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
184 return []net.IP{net.ParseIP("10.0.0.7")}, nil
185 }}
186 err := w.sync(m)
187 if err == nil || !strings.Contains(err.Error(), "10.0.0.7") {
188 t.Fatalf("sync = %v, want a refusal naming 10.0.0.7", err)
189 }
190}
191
192// A refusal is a sync failure like any other: the sweep records it on
193// the mirror, where repo mirror list shows it.
194func TestSweepRecordsTheRefusal(t *testing.T) {
195 root := t.TempDir()
196 st, m, _ := local(t, root, "https://mirror.test/x.git")
197 var cfg config.Config
198 cfg.Server.Root = root
199 cfg.Mirrors.PullIntervalMinutes = 15
200 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
201 return []net.IP{net.ParseIP("100.64.0.9")}, nil
202 }}
203 w.sweep()
204 ms, err := st.ListMirrors(m.RepoID)
205 if err != nil || len(ms) != 1 {
206 t.Fatalf("mirrors: %v %v", ms, err)
207 }
208 if !strings.Contains(ms[0].LastError, "100.64.0.9") {
209 t.Fatalf("last error = %q", ms[0].LastError)
210 }
211}
212
213func TestSyncRefusesAnEmptyAnswer(t *testing.T) {
214 root := t.TempDir()
215 st, m, _ := local(t, root, "https://mirror.test/x.git")
216 var cfg config.Config
217 cfg.Server.Root = root
218 cfg.Webhooks.AllowLocal = true
219 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
220 return nil, nil
221 }}
222 if err := w.sync(m); err == nil || !strings.Contains(err.Error(), "no address") {
223 t.Fatalf("sync = %v, want a refusal", err)
224 }
225}
226
227func TestSyncRefusesANonHTTPScheme(t *testing.T) {
228 root := t.TempDir()
229 st, m, _ := local(t, root, "ssh://mirror.test/x.git")
230 var cfg config.Config
231 cfg.Server.Root = root
232 cfg.Webhooks.AllowLocal = true
233 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
234 t.Fatal("looked up a host for an ssh URL")
235 return nil, nil
236 }}
237 if err := w.sync(m); err == nil || !strings.Contains(err.Error(), "not http or https") {
238 t.Fatalf("sync = %v, want a refusal", err)
239 }
240}
241
242func TestPinArgs(t *testing.T) {
243 u, _ := url.Parse("https://git.example/x.git")
244 got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")})
245 want := []string{"-c", "http.followRedirects=false",
246 "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"}
247 if !slices.Equal(got, want) {
248 t.Fatalf("https: %q", got)
249 }
250 u, _ = url.Parse("http://git.example:8080/x.git")
251 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" {
252 t.Fatalf("http with port: %q", got)
253 }
254 // An address literal is its own resolution; there is nothing to pin.
255 u, _ = url.Parse("https://203.0.113.5/x.git")
256 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) {
257 t.Fatalf("literal: %q", got)
258 }
259}