internal/control/ghimport_test.go

v1.43.1
gitbay/internal/control/ghimport_test.go history · blame · raw

191 lines · 6955 bytes

  1package control
  2
  3import (
  4	"context"
  5	"net"
  6	"net/http"
  7	"net/http/cgi"
  8	"net/http/httptest"
  9	"net/url"
 10	"os"
 11	"path/filepath"
 12	"slices"
 13	"strings"
 14	"testing"
 15
 16	"gitbay.org/gitbay/internal/gitutil"
 17	"gitbay.org/gitbay/internal/protocol"
 18)
 19
 20// pullUpstream serves a bare repository whose refs/pull/1/head is one
 21// commit over smart HTTP, and returns its port and that commit.
 22func pullUpstream(t *testing.T) (string, string) {
 23	t.Helper()
 24	git := gitRunner(t)
 25	parent := t.TempDir()
 26	bare := filepath.Join(parent, "o", "r.git")
 27	work := filepath.Join(parent, "work")
 28	git(parent, "init", "-q", "--bare", "--initial-branch=main", bare)
 29	git(parent, "init", "-q", "--initial-branch=main", work)
 30	git(work, "commit", "-q", "--allow-empty", "-m", "pr")
 31	git(work, "push", "-q", bare, "HEAD:refs/pull/1/head")
 32	sha := strings.TrimSpace(git(work, "rev-parse", "HEAD"))
 33	execPath := strings.TrimSpace(git(parent, "--exec-path"))
 34	srv := httptest.NewServer(&cgi.Handler{
 35		Path: filepath.Join(execPath, "git-http-backend"),
 36		Env:  []string{"GIT_PROJECT_ROOT=" + parent, "GIT_HTTP_EXPORT_ALL=1"},
 37	})
 38	t.Cleanup(srv.Close)
 39	u, _ := url.Parse(srv.URL)
 40	return u.Port(), sha
 41}
 42
 43// stubLookup answers every host with ip and records what was asked.
 44func stubLookup(t *testing.T, ip string) *[]string {
 45	t.Helper()
 46	var asked []string
 47	prev := importLookup
 48	importLookup = func(ctx context.Context, host string) ([]net.IP, error) {
 49		asked = append(asked, host)
 50		return []net.IP{net.ParseIP(ip)}, nil
 51	}
 52	t.Cleanup(func() { importLookup = prev })
 53	return &asked
 54}
 55
 56// pulls.test does not resolve, and the daemon's environment points git
 57// at a proxy and rewrites the URL to a dead port: the fetch works only
 58// because git was pinned to the checked address and ran with its own
 59// environment (#301).
 60func TestFetchPullHeadsIsPinned(t *testing.T) {
 61	port, sha := pullUpstream(t)
 62	t.Setenv("http_proxy", "http://127.0.0.1:1")
 63	t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
 64	global := filepath.Join(t.TempDir(), "gitconfig")
 65	rewrite := "[url \"http://127.0.0.1:1/\"]\n\tinsteadOf = http://pulls.test:" + port + "/\n"
 66	if err := os.WriteFile(global, []byte(rewrite), 0o644); err != nil {
 67		t.Fatal(err)
 68	}
 69	t.Setenv("GIT_CONFIG_GLOBAL", global)
 70	c, errOut, _, root := importCtx(t, true)
 71	asked := stubLookup(t, "127.0.0.1")
 72	dir := filepath.Join(root, "dest.git")
 73	if err := gitutil.InitBare(dir, "main", ""); err != nil {
 74		t.Fatal(err)
 75	}
 76	if !fetchPullHeads(c, dir, "http://pulls.test:"+port+"/o/r.git", "") {
 77		t.Fatalf("fetch failed: %s", errOut.String())
 78	}
 79	got := strings.TrimSpace(gitRunner(t)(dir, "rev-parse", "refs/gh-pull/1"))
 80	if got != sha {
 81		t.Fatalf("refs/gh-pull/1 = %s, want %s", got, sha)
 82	}
 83	if !slices.Equal(*asked, []string{"pulls.test"}) {
 84		t.Fatalf("looked up %v", *asked)
 85	}
 86}
 87
 88// A git host that resolves to loopback is refused on a default
 89// instance; the fetch reports it and fetches nothing.
 90func TestFetchPullHeadsRefusesALocalAddress(t *testing.T) {
 91	port, _ := pullUpstream(t)
 92	c, errOut, _, root := importCtx(t, false)
 93	stubLookup(t, "127.0.0.1")
 94	dir := filepath.Join(root, "dest.git")
 95	if err := gitutil.InitBare(dir, "main", ""); err != nil {
 96		t.Fatal(err)
 97	}
 98	if fetchPullHeads(c, dir, "http://pulls.test:"+port+"/o/r.git", "sekrit") {
 99		t.Fatal("fetched from a local address")
100	}
101	if !strings.Contains(errOut.String(), "private or local address") || strings.Contains(errOut.String(), "sekrit") {
102		t.Fatalf("stderr %q", errOut.String())
103	}
104	if refExists(dir, "refs/gh-pull/1") {
105		t.Fatal("refs/gh-pull/1 was fetched")
106	}
107}
108
109// apiServer serves an empty issue list for o/r, plus whatever extra
110// registers, and returns the server's port.
111func apiServer(t *testing.T, extra func(mux *http.ServeMux)) string {
112	t.Helper()
113	mux := http.NewServeMux()
114	mux.HandleFunc("/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) {
115		w.Write([]byte("[]"))
116	})
117	if extra != nil {
118		extra(mux)
119	}
120	srv := httptest.NewServer(mux)
121	t.Cleanup(srv.Close)
122	u, _ := url.Parse(srv.URL)
123	return u.Port()
124}
125
126// api.test does not resolve; the import reaches the API only because the
127// client dialed the address import-issues looked up and checked (#301).
128func TestImportIssuesConnectsToTheCheckedAddress(t *testing.T) {
129	port := apiServer(t, nil)
130	c, errOut, _, _ := importCtx(t, true)
131	asked := stubLookup(t, "127.0.0.1")
132	code := Dispatch(c, []string{"repo", "import-issues", "alice/app", "--from", "o/r", "--api-base", "http://api.test:" + port})
133	if code != protocol.ExitOK {
134		t.Fatalf("exit %d: %s", code, errOut.String())
135	}
136	if len(*asked) == 0 || (*asked)[0] != "api.test" {
137		t.Fatalf("looked up %v", *asked)
138	}
139}
140
141// A redirect is refused and its target never asked, although the dialer
142// would land it on the checked address.
143func TestImportIssuesRefusesARedirect(t *testing.T) {
144	var port string
145	reached := false
146	port = apiServer(t, func(mux *http.ServeMux) {
147		mux.HandleFunc("/repos/o/x/issues", func(w http.ResponseWriter, r *http.Request) {
148			http.Redirect(w, r, "http://other.test:"+port+"/repos/o/x/moved", http.StatusMovedPermanently)
149		})
150		mux.HandleFunc("/repos/o/x/moved", func(w http.ResponseWriter, r *http.Request) {
151			reached = true
152			w.Write([]byte("[]"))
153		})
154	})
155	c, errOut, _, _ := importCtx(t, true)
156	stubLookup(t, "127.0.0.1")
157	code := Dispatch(c, []string{"repo", "import-issues", "alice/app", "--from", "o/x", "--api-base", "http://api.test:" + port})
158	if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "refusing redirect to http://other.test") {
159		t.Fatalf("exit %d: %s", code, errOut.String())
160	}
161	if reached {
162		t.Fatal("followed a redirect to another host")
163	}
164}
165
166// An API base that resolves to private space is refused on a default
167// instance before anything connects.
168func TestImportIssuesRefusesAPrivateAPIBase(t *testing.T) {
169	c, errOut, _, _ := importCtx(t, false)
170	asked := stubLookup(t, "10.0.0.1")
171	code := Dispatch(c, []string{"repo", "import-issues", "alice/app", "--from", "o/r", "--api-base", "https://api.test"})
172	if code != protocol.ExitUsage || !strings.Contains(errOut.String(), "private or local address") {
173		t.Fatalf("exit %d: %s", code, errOut.String())
174	}
175	if !slices.Equal(*asked, []string{"api.test"}) {
176		t.Fatalf("looked up %v", *asked)
177	}
178}
179
180// --api-base takes a plain http or https URL: no credentials, query,
181// fragment or other scheme, and nothing of a refused one is echoed.
182func TestImportIssuesRefusesAnAPIBaseShape(t *testing.T) {
183	for _, base := range []string{"https://abc@api.test", "https://api.test/?abc", "https://api.test/#abc", "ftp://api.test/abc"} {
184		c, errOut, _, _ := importCtx(t, true)
185		asked := stubLookup(t, "127.0.0.1")
186		code := Dispatch(c, []string{"repo", "import-issues", "alice/app", "--from", "o/r", "--api-base", base})
187		if code != protocol.ExitUsage || len(*asked) != 0 || strings.Contains(errOut.String(), "abc") {
188			t.Fatalf("%s: exit %d, looked up %v: %s", base, code, *asked, errOut.String())
189		}
190	}
191}