internal/control/ghimport_test.go
191 lines · 6955 bytes
9 symbols in this file
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}