e2e/mirror_test.go

v1.42.0
gitbay/e2e/mirror_test.go history · blame · raw

201 lines · 8118 bytes

  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}