e2e/mirror_test.go

v0.2.0
gitbay/e2e/mirror_test.go history · blame · raw

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}