internal/mirror/mirror_test.go

0338e6ace3de199d5fc383852649919b68ef3e42
gitbay/internal/mirror/mirror_test.go history · blame · raw

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}