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