e2e/ghimport_test.go

f3f7422f62a73bb798f9cf9c5f4c344212a27f60
gitbay/e2e/ghimport_test.go history · blame · raw

265 lines · 11285 bytes

  1package e2e
  2
  3import (
  4	"fmt"
  5	"net/http"
  6	"net/http/httptest"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10	"testing"
 11)
 12
 13// fakeGitHub serves just enough of the GitHub REST API for the importer.
 14func fakeGitHub(t *testing.T) *httptest.Server {
 15	t.Helper()
 16	mux := http.NewServeMux()
 17	auth := func(w http.ResponseWriter, r *http.Request) bool {
 18		if r.Header.Get("Authorization") != "Bearer sekrit" {
 19			w.WriteHeader(401)
 20			return false
 21		}
 22		return true
 23	}
 24	mux.HandleFunc("/repos/octo/legacy/issues", func(w http.ResponseWriter, r *http.Request) {
 25		if !auth(w, r) {
 26			return
 27		}
 28		if r.URL.Query().Get("page") != "1" {
 29			fmt.Fprint(w, "[]")
 30			return
 31		}
 32		fmt.Fprint(w, `[
 33		 {"number":1,"title":"old bug","body":"it crashed","state":"closed",
 34		  "created_at":"2019-03-04T10:00:00Z","user":{"login":"octofan"},
 35		  "labels":[{"name":"bug"}],"comments":0},
 36		 {"number":2,"title":"add feature","body":"the patch","state":"closed",
 37		  "created_at":"2020-06-01T10:00:00Z","user":{"login":"drive-by"},
 38		  "labels":[],"comments":1,"pull_request":{}},
 39		 {"number":3,"title":"still open","body":"discuss","state":"open",
 40		  "created_at":"2021-01-01T10:00:00Z","user":{"login":"octofan"},
 41		  "labels":[],"comments":2}
 42		]`)
 43	})
 44	mux.HandleFunc("/repos/octo/legacy/pulls/2", func(w http.ResponseWriter, r *http.Request) {
 45		if !auth(w, r) {
 46			return
 47		}
 48		fmt.Fprint(w, `{"merged_at":"2020-06-02T10:00:00Z",
 49		 "head":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ref":"feature"},
 50		 "base":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","ref":"main"}}`)
 51	})
 52	comments := func(payload string) http.HandlerFunc {
 53		return func(w http.ResponseWriter, r *http.Request) {
 54			if !auth(w, r) {
 55				return
 56			}
 57			if r.URL.Query().Get("page") != "1" {
 58				fmt.Fprint(w, "[]")
 59				return
 60			}
 61			fmt.Fprint(w, payload)
 62		}
 63	}
 64	mux.HandleFunc("/repos/octo/legacy/issues/2/comments", comments(
 65		`[{"id":101,"body":"nice patch","created_at":"2020-06-01T11:00:00Z","user":{"login":"maintainer"}}]`))
 66	mux.HandleFunc("/repos/octo/legacy/issues/3/comments", comments(
 67		`[{"id":102,"body":"me too","created_at":"2021-01-02T10:00:00Z","user":{"login":"other"}},
 68		  {"id":103,"body":"still happening","created_at":"2021-02-01T10:00:00Z","user":{"login":"octofan"}}]`))
 69	srv := httptest.NewServer(mux)
 70	t.Cleanup(srv.Close)
 71	return srv
 72}
 73
 74func TestGitHubIssueImport(t *testing.T) {
 75	// allow_local lets --api-base reach the loopback fake; a default
 76	// instance refuses it (see the SSRF check at the end).
 77	inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n")
 78	aliceKey := inst.newKey(t, "alice")
 79	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
 80
 81	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
 82		t.Fatalf("repo create: %s", errOut)
 83	}
 84	work := t.TempDir()
 85	env := inst.gitEnv(aliceKey)
 86	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
 87	dir := filepath.Join(work, "w")
 88	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
 89	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 90	mustGit(t, dir, env, "add", ".")
 91	mustGit(t, dir, env, "commit", "-q", "-m", "base")
 92	mustGit(t, dir, env, "push", "-q", "origin", "main")
 93
 94	gh := fakeGitHub(t)
 95	host := strings.TrimPrefix(gh.URL, "http://")
 96	out, errOut, code := inst.ssh(t, aliceKey, "sekrit\n", "repo", "import-issues", "alice/app",
 97		"--from", "octo/legacy", "--token-stdin", "--api-base", gh.URL)
 98	if code != 0 {
 99		t.Fatalf("import: %s", errOut)
100	}
101	if !strings.Contains(out, "imported 2 issues, 1 merge requests, 3 comments") {
102		t.Fatalf("summary: %s", out)
103	}
104
105	// Issue #1 (GitHub #1): closed, labeled, attributed.
106	out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
107	if !strings.Contains(out, "old bug") || !strings.Contains(out, `"state":"closed"`) ||
108		!strings.Contains(out, `"labels":["bug"]`) ||
109		!strings.Contains(out, "imported issue "+host+"/octo/legacy#1") ||
110		!strings.Contains(out, "@octofan, 2019-03-04") {
111		t.Fatalf("issue 1: %s", out)
112	}
113	// Issue #2 (GitHub #3): open, two attributed comments.
114	out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json")
115	if !strings.Contains(out, "still open") || !strings.Contains(out, `"state":"open"`) ||
116		!strings.Contains(out, "me too") || !strings.Contains(out, "@other, 2021-01-02") {
117		t.Fatalf("issue 2: %s", out)
118	}
119	// MR !1 (GitHub PR #2): merged, discussion imported.
120	out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json")
121	if !strings.Contains(out, "add feature") || !strings.Contains(out, `"state":"merged"`) ||
122		!strings.Contains(out, "imported pull request "+host+"/octo/legacy#2") ||
123		!strings.Contains(out, "nice patch") {
124		t.Fatalf("mr 1: %s", out)
125	}
126
127	// Re-running imports nothing new — fully resumable.
128	out, _, code = inst.ssh(t, aliceKey, "sekrit\n", "repo", "import-issues", "alice/app",
129		"--from", "octo/legacy", "--token-stdin", "--api-base", gh.URL)
130	if code != 0 || !strings.Contains(out, "imported 0 issues, 0 merge requests, 0 comments (3 items already imported)") {
131		t.Fatalf("re-run: %s", out)
132	}
133	out, _, _ = inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--state", "all")
134	if strings.Count(out, "\n") != 2 {
135		t.Fatalf("issues duplicated:\n%s", out)
136	}
137
138	// A wrong token surfaces the API error.
139	if _, errOut, code := inst.ssh(t, aliceKey, "wrong\n", "repo", "import-issues", "alice/app",
140		"--from", "octo/legacy", "--token-stdin", "--api-base", gh.URL); code == 0 || !strings.Contains(errOut, "401") {
141		t.Fatalf("bad token: exit %d, %s", code, errOut)
142	}
143}
144
145func TestGitHubImportSSRFGuard(t *testing.T) {
146	inst := startInstance(t) // allow_local off: default posture
147	aliceKey := inst.newKey(t, "alice")
148	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
149	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
150		t.Fatal("repo create failed")
151	}
152	_, errOut, code := inst.ssh(t, aliceKey, "", "repo", "import-issues", "alice/app",
153		"--from", "octo/legacy", "--api-base", "http://127.0.0.1:9999")
154	if code != 2 || !strings.Contains(errOut, "SSRF") {
155		t.Fatalf("local api-base allowed: exit %d, %s", code, errOut)
156	}
157}
158
159// fakeForgejo serves the Forgejo shape of the same API under /api/v1:
160// GitHub's issue, pull and comment objects, but pages sized by `limit`,
161// order by `sort=oldest`, a /version endpoint, and a comments endpoint
162// that ignores `page` and returns everything every time.
163func fakeForgejo(t *testing.T) *httptest.Server {
164	t.Helper()
165	mux := http.NewServeMux()
166	mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
167		fmt.Fprint(w, `{"version":"9.0.0+gitea-1.22.0"}`)
168	})
169	mux.HandleFunc("/api/v1/repos/octo/legacy/issues", func(w http.ResponseWriter, r *http.Request) {
170		q := r.URL.Query()
171		if q.Get("page") != "1" {
172			fmt.Fprint(w, "[]")
173			return
174		}
175		items := []string{
176			`{"number":1,"title":"old bug","body":"it crashed","state":"closed",
177			  "created_at":"2019-03-04T10:00:00+01:00","user":{"login":"octofan"},
178			  "labels":[{"name":"bug"}],"comments":0}`,
179			`{"number":2,"title":"add feature","body":"the patch","state":"closed",
180			  "created_at":"2020-06-01T10:00:00Z","user":{"login":"drive-by"},
181			  "labels":[],"comments":1,"pull_request":{"merged":true}}`,
182			`{"number":3,"title":"still open","body":"discuss","state":"open",
183			  "created_at":"2021-01-01T10:00:00Z","user":{"login":"octofan"},
184			  "labels":[],"comments":2}`,
185		}
186		// Forgejo's default is newest first; only sort=oldest gives
187		// the order local numbering depends on.
188		if q.Get("sort") != "oldest" || q.Get("limit") == "" {
189			items[0], items[2] = items[2], items[0]
190		}
191		fmt.Fprint(w, "["+strings.Join(items, ",")+"]")
192	})
193	mux.HandleFunc("/api/v1/repos/octo/legacy/pulls/2", func(w http.ResponseWriter, r *http.Request) {
194		fmt.Fprint(w, `{"merged_at":"2020-06-02T10:00:00Z",
195		 "head":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ref":"feature"},
196		 "base":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","ref":"main"}}`)
197	})
198	comments := func(payload string) http.HandlerFunc {
199		return func(w http.ResponseWriter, r *http.Request) {
200			// No paging on this endpoint: the real one ignores `page`
201			// and returns everything, so a caller walking pages never
202			// stops. Answer a second page with an error so the test
203			// fails instead of hanging.
204			if p := r.URL.Query().Get("page"); p != "" && p != "1" {
205				http.Error(w, "unpaged endpoint asked for page "+p, 500)
206				return
207			}
208			fmt.Fprint(w, payload)
209		}
210	}
211	mux.HandleFunc("/api/v1/repos/octo/legacy/issues/2/comments", comments(
212		`[{"id":101,"body":"nice patch","created_at":"2020-06-01T11:00:00Z","user":{"login":"maintainer"}}]`))
213	mux.HandleFunc("/api/v1/repos/octo/legacy/issues/3/comments", comments(
214		`[{"id":102,"body":"me too","created_at":"2021-01-02T10:00:00Z","user":{"login":"other"}},
215		  {"id":103,"body":"still happening","created_at":"2021-02-01T10:00:00Z","user":{"login":"octofan"}}]`))
216	srv := httptest.NewServer(mux)
217	t.Cleanup(srv.Close)
218	return srv
219}
220
221func TestForgejoIssueImport(t *testing.T) {
222	inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n")
223	aliceKey := inst.newKey(t, "alice")
224	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
225	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
226		t.Fatalf("repo create: %s", errOut)
227	}
228	fj := fakeForgejo(t)
229	host := strings.TrimPrefix(fj.URL, "http://")
230	// --from as the repository's URL on the site, the way a Codeberg
231	// user copies it from the address bar.
232	out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "import-issues", "alice/app",
233		"--from", fj.URL+"/octo/legacy", "--api-base", fj.URL+"/api/v1")
234	if code != 0 {
235		t.Fatalf("import: %s", errOut)
236	}
237	if !strings.Contains(out, "imported 2 issues, 1 merge requests, 3 comments") {
238		t.Fatalf("summary: %s", out)
239	}
240	// Oldest first, attributed to the site the API base belongs to.
241	out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
242	if !strings.Contains(out, "old bug") || !strings.Contains(out, `"state":"closed"`) ||
243		!strings.Contains(out, `"labels":["bug"]`) ||
244		!strings.Contains(out, "imported issue "+host+"/octo/legacy#1") ||
245		!strings.Contains(out, "@octofan, 2019-03-04") {
246		t.Fatalf("issue 1: %s", out)
247	}
248	out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json")
249	if !strings.Contains(out, "still open") || !strings.Contains(out, "me too") ||
250		!strings.Contains(out, "still happening") {
251		t.Fatalf("issue 2: %s", out)
252	}
253	out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json")
254	if !strings.Contains(out, "add feature") || !strings.Contains(out, `"state":"merged"`) ||
255		!strings.Contains(out, "imported pull request "+host+"/octo/legacy#2") ||
256		!strings.Contains(out, "nice patch") {
257		t.Fatalf("mr 1: %s", out)
258	}
259	// Re-running imports nothing new.
260	out, _, code = inst.ssh(t, aliceKey, "", "repo", "import-issues", "alice/app",
261		"--from", "octo/legacy", "--api-base", fj.URL+"/api/v1")
262	if code != 0 || !strings.Contains(out, "imported 0 issues, 0 merge requests, 0 comments (3 items already imported)") {
263		t.Fatalf("re-run: %s", out)
264	}
265}