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