e2e/ghimport_test.go

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

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