e2e/ghimport_test.go
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}