1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
|
package forge
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os/exec"
"strings"
"testing"
)
type apiClient struct {
t *testing.T
h http.Handler
}
func (a apiClient) do(method, path, auth, body string) *httptest.ResponseRecorder {
a.t.Helper()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.RemoteAddr = "192.0.2.7:1000"
if body != "" && strings.HasPrefix(strings.TrimSpace(body), "{") {
r.Header.Set("Content-Type", "application/json")
}
switch {
case strings.HasPrefix(auth, "basic:"):
name, pass, _ := strings.Cut(strings.TrimPrefix(auth, "basic:"), ":")
r.SetBasicAuth(name, pass)
case strings.HasPrefix(auth, "cookie:"):
r.Header.Set("Cookie", sessionCookie+"="+strings.TrimPrefix(auth, "cookie:"))
case auth != "":
r.Header.Set("Authorization", "Bearer "+auth)
}
w := httptest.NewRecorder()
a.h.ServeHTTP(w, r)
return w
}
func (a apiClient) want(code int, method, path, auth, body string) map[string]any {
a.t.Helper()
w := a.do(method, path, auth, body)
if w.Code != code {
a.t.Fatalf("%s %s: want %d, got %d %s", method, path, code, w.Code, w.Body.String())
}
out := map[string]any{}
if w.Body.Len() > 0 && strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
a.t.Fatalf("%s %s: %v", method, path, err)
}
}
return out
}
func items(v map[string]any) []any {
list, _ := v["items"].([]any)
return list
}
func TestAPI(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
s, h := testServer(t)
a := apiClient{t, h}
alice, _ := s.store.CreateUser("alice", "correct horse")
bob, _ := s.store.CreateUser("bob", "battery staple")
age(t, s, alice)
age(t, s, bob)
at, _ := s.store.CreateToken(alice.ID, "all", scopeAll)
bt, _ := s.store.CreateToken(bob.ID, "all", scopeAll)
rt, _ := s.store.CreateToken(alice.ID, "reader", scopeRead)
gt, _ := s.store.CreateToken(alice.ID, "gists", scopeGist)
index := a.want(200, "GET", "/api", "", "")
if routes, _ := index["routes"].([]any); len(routes) < 80 {
t.Errorf("index lists %d routes", len(routes))
}
r := httptest.NewRequest("GET", "/api", nil)
r.Header.Set("Accept", "text/html")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 200 || !strings.Contains(w.Body.String(), "/~:owner/:repo/pulls/:number/merge") {
t.Errorf("html index: %d", w.Code)
}
a.want(401, "GET", "/api/me", "", "")
a.want(401, "GET", "/api/me", "gp_nonsense", "")
me := a.want(200, "GET", "/api/me", at, "")
if me["handle"] != "~alice" || me["scope"] != "all" || me["via"] != "token" {
t.Errorf("me: %v", me)
}
if v := a.want(200, "GET", "/api/me", "basic:alice:"+at, ""); v["via"] != "token" {
t.Errorf("basic token: %v", v)
}
a.want(404, "GET", "/api/nowhere", at, "")
if w := a.do("PUT", "/api/me", at, "{}"); w.Code != 405 || !strings.Contains(w.Header().Get("Allow"), "PATCH") {
t.Errorf("405: %d %v", w.Code, w.Header())
}
if w := a.do("OPTIONS", "/api/me", "", ""); w.Code != 204 || w.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Errorf("preflight: %d", w.Code)
}
a.want(403, "POST", "/api/repos", rt, `{"name":"nope"}`)
a.want(403, "POST", "/api/repos", gt, `{"name":"nope"}`)
a.want(403, "POST", "/api/me/tokens", at, `{"title":"x"}`)
tok := a.want(201, "POST", "/api/me/tokens", "basic:alice:correct horse", `{"title":"cli","scope":"read"}`)
if !strings.HasPrefix(tok["token"].(string), "gp_") || tok["scope"] != "read" {
t.Errorf("token: %v", tok)
}
a.want(400, "POST", "/api/me/tokens", "basic:alice:correct horse", `{"scope":"root"}`)
a.want(400, "POST", "/api/repos", at, `{"name":"x","bogus":1}`)
login := post(h, "/login", url.Values{"name": {"alice"}, "password": {"correct horse"}}, "")
cookie := session(login)
if v := a.want(200, "GET", "/api/me", "cookie:"+cookie, ""); v["via"] != "session" {
t.Errorf("session: %v", v)
}
r = httptest.NewRequest("GET", "/api/me", nil)
r.Header.Set("Cookie", sessionCookie+"="+cookie)
r.Header.Set("Sec-Fetch-Site", "cross-site")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 401 {
t.Errorf("cross-site cookie accepted: %d", w.Code)
}
repo := a.want(201, "POST", "/api/repos", at, `{"name":"proj","description":"a project","tags":["ocaml"]}`)
if repo["path"] != "~alice/proj" || repo["visibility"] != "public" {
t.Fatalf("repo: %v", repo)
}
a.want(404, "GET", "/api/~alice/proj/tree/main", at, "")
g := Git{Dir: s.repoDir(Repo{Owner: alice, Name: "proj"})}
base := commitFile(t, g, "main", "", "README.md", "# proj\n\nhello\n")
a.want(200, "PATCH", "/api/~alice/proj", at, `{"description":"renamed soon","visibility":"unlisted"}`)
a.want(403, "PATCH", "/api/~alice/proj", bt, `{"description":"mine"}`)
put := a.want(201, "PUT", "/api/~alice/proj/tree/main/src/lib.ml", at, `{"content":"let x = 1\n","message":"add lib"}`)
if put["subject"] != "add lib" {
t.Errorf("put: %v", put)
}
a.want(409, "PUT", "/api/~alice/proj/tree/main/src/lib.ml", at, `{"content":"let x = 2\n","parent":"`+base+`"}`)
bin := base64.StdEncoding.EncodeToString([]byte{0, 1, 2, 255})
a.want(201, "PUT", "/api/~alice/proj/tree/main/blob.bin", at, `{"content":"`+bin+`","encoding":"base64"}`)
a.want(403, "PUT", "/api/~alice/proj/tree/main/x", bt, `{"content":"x"}`)
dir := a.want(200, "GET", "/api/~alice/proj/tree/main", "", "")
if entries, _ := dir["entries"].([]any); len(entries) != 3 {
t.Errorf("tree: %v", dir)
}
file := a.want(200, "GET", "/api/~alice/proj/tree/main/src/lib.ml", "", "")
if file["content"] != "let x = 1\n" || file["encoding"] != "utf-8" {
t.Errorf("file: %v", file)
}
if f := a.want(200, "GET", "/api/~alice/proj/tree/main/blob.bin", "", ""); f["encoding"] != "base64" || f["content"] != bin {
t.Errorf("binary: %v", f)
}
if w := a.do("GET", "/api/~alice/proj/raw/main/src/lib.ml", "", ""); w.Body.String() != "let x = 1\n" {
t.Errorf("raw: %q", w.Body.String())
}
log := a.want(200, "GET", "/api/~alice/proj/log/main?limit=2", "", "")
if len(items(log)) != 2 || log["next"] == nil {
t.Errorf("log: %v", log)
}
commit := a.want(200, "GET", "/api/~alice/proj/commit/"+base+"?patch=1", "", "")
if files, _ := commit["files"].([]any); len(files) != 1 {
t.Errorf("commit: %v", commit)
}
a.want(200, "GET", "/api/~alice/proj/blame/main/README.md", "", "")
if rd := a.want(200, "GET", "/api/~alice/proj/readme", "", ""); !strings.Contains(rd["html"].(string), "<h1") {
t.Errorf("readme: %v", rd)
}
if gr := a.want(200, "GET", "/api/~alice/proj/grep?q=hello", "", ""); len(gr["matches"].([]any)) != 1 {
t.Errorf("grep: %v", gr)
}
if w := a.do("GET", "/api/~alice/proj/archive/main.tar.gz", "", ""); w.Code != 200 || w.Body.Len() < 100 {
t.Errorf("archive: %d", w.Code)
}
a.want(200, "DELETE", "/api/~alice/proj/tree/main/blob.bin?message=drop", at, "")
refs := a.want(200, "GET", "/api/~alice/proj/refs", "", "")
if len(items(refs)) != 1 {
t.Errorf("refs: %v", refs)
}
a.want(201, "POST", "/api/~alice/proj/labels", at, `{"name":"bug","colour":"#ff0000"}`)
issue := a.want(201, "POST", "/api/~alice/proj/issues", bt, `{"title":"it breaks","body":"badly"}`)
if issue["author"] != "~bob" || issue["state"] != "open" {
t.Errorf("issue: %v", issue)
}
a.want(201, "POST", "/api/~alice/proj/issues", bt, `{"title":"second"}`)
a.want(201, "POST", "/api/~alice/proj/issues", bt, `{"title":"third"}`)
page := a.want(200, "GET", "/api/~alice/proj/issues?limit=2", "", "")
if page["total"].(float64) != 3 || len(items(page)) != 2 || page["next"] == nil {
t.Errorf("paging: %v", page)
}
a.want(403, "PATCH", "/api/~alice/proj/issues/1", bt, `{"labels":["bug"]}`)
if v := a.want(200, "PATCH", "/api/~alice/proj/issues/1", at, `{"labels":["bug"],"assignees":["~bob"]}`); len(v["labels"].([]any)) != 1 {
t.Errorf("labels: %v", v)
}
if v := a.want(200, "GET", "/api/~alice/proj/issues?label=bug", "", ""); len(items(v)) != 1 {
t.Errorf("label filter: %v", v)
}
a.want(201, "POST", "/api/~alice/proj/issues/1/comments", at, `{"body":"looking"}`)
a.want(200, "PATCH", "/api/~alice/proj/issues/1", bt, `{"state":"closed"}`)
if v := a.want(200, "GET", "/api/~alice/proj/issues/1", "", ""); v["state"] != "closed" || v["comments"].(float64) != 1 {
t.Errorf("closed: %v", v)
}
if n := a.want(200, "GET", "/api/me/notifications", bt, ""); len(items(n)) == 0 {
t.Error("bob has no notifications")
}
head, _ := g.Resolve("refs/heads/main")
commitFile(t, g, "feature", head, "feature.txt", "new\n")
a.want(400, "POST", "/api/~alice/proj/pulls", bt, `{"title":"x","head":"nope"}`)
pull := a.want(201, "POST", "/api/~alice/proj/pulls", at, `{"title":"add feature","head":"feature","base":"main"}`)
if pull["mergeable"] != true || pull["head"].(map[string]any)["branch"] != "feature" {
t.Fatalf("pull: %v", pull)
}
a.want(204, "PUT", "/api/~alice/proj/protections/main", at, `{"approvals":1}`)
a.want(409, "POST", "/api/~alice/proj/pulls/1/merge", at, `{"strategy":"squash"}`)
a.want(403, "POST", "/api/~alice/proj/pulls/1/reviews", at, `{"verdict":"approved"}`)
a.want(201, "POST", "/api/~alice/proj/pulls/1/reviews", bt, `{"verdict":"approved","body":"lgtm"}`)
note := a.want(201, "POST", "/api/~alice/proj/pulls/1/notes", bt, `{"path":"feature.txt","side":"n","line":1,"body":"nit"}`)
a.want(204, "PATCH", "/api/~alice/proj/pulls/1/notes/"+jsonID(note), at, `{"resolved":true}`)
if v := a.want(200, "GET", "/api/~alice/proj/pulls/1/files?patch=1", "", ""); len(items(v)) != 1 {
t.Errorf("files: %v", v)
}
if v := a.want(200, "GET", "/api/~alice/proj/pulls/1/commits", "", ""); len(items(v)) != 1 {
t.Errorf("commits: %v", v)
}
a.want(403, "POST", "/api/~alice/proj/pulls/1/merge", bt, `{}`)
merged := a.want(200, "POST", "/api/~alice/proj/pulls/1/merge", at, `{"strategy":"squash"}`)
if merged["state"] != "merged" || merged["merged_as"] == nil {
t.Errorf("merged: %v", merged)
}
if v := a.want(200, "GET", "/api/~alice/proj/pulls?state=merged", "", ""); len(items(v)) != 1 {
t.Errorf("merged list: %v", v)
}
fork := a.want(201, "POST", "/api/~alice/proj/forks", bt, "")
if fork["fork_of"] != "~alice/proj" {
t.Errorf("fork: %v", fork)
}
a.want(409, "POST", "/api/~alice/proj/forks", at, "")
a.want(204, "PUT", "/api/~alice/proj/star", bt, "")
if v := a.want(200, "GET", "/api/~alice/proj", "", ""); v["stars"].(float64) != 1 {
t.Errorf("stars: %v", v)
}
a.want(204, "PUT", "/api/~alice/follow", bt, "")
if v := a.want(200, "GET", "/api/~alice/followers", "", ""); len(items(v)) != 1 {
t.Errorf("followers: %v", v)
}
a.want(204, "PUT", "/api/~alice/proj/collaborators/~bob", at, "")
if v := a.want(200, "GET", "/api/me/repos", bt, ""); v["total"].(float64) != 2 {
t.Errorf("bob's repos: %v", v)
}
a.want(204, "DELETE", "/api/~alice/proj/collaborators/~bob", at, "")
a.want(200, "PATCH", "/api/~alice/proj", at, `{"name":"renamed","archived":true}`)
a.want(403, "POST", "/api/~alice/renamed/issues", bt, `{"title":"archived"}`)
a.want(404, "GET", "/api/~alice/proj", "", "")
a.want(400, "DELETE", "/api/~alice/renamed", at, "")
a.want(204, "DELETE", "/api/~bob/proj?confirm=proj", bt, "")
for i := s.store.RepoCount(bob.ID); i < maxRepos; i++ {
s.store.CreateRepo(bob, fmt.Sprintf("spare%d", i), "", Public, "")
}
a.want(403, "POST", "/api/repos", bt, `{"name":"seventh"}`)
a.want(403, "POST", "/api/~alice/renamed/forks", bt, "")
a.want(403, "GET", "/api/admin/stats", at, "")
s.store.SetAdmin("alice", true)
if v := a.want(200, "GET", "/api/admin/stats", at, ""); v["users"].(float64) != 2 {
t.Errorf("stats: %v", v)
}
a.want(403, "GET", "/api/admin/stats", rt, "")
gist := a.want(201, "POST", "/api/gists", gt, `{"description":"notes","visibility":"public","files":[{"name":"a.txt","content":"one"},{"name":"b.txt","content":"two"}]}`)
id := gist["id"].(string)
a.want(403, "PATCH", "/api/gists/"+id, bt, `{"description":"mine"}`)
a.want(409, "PATCH", "/api/gists/"+id, gt, `{"head":"`+strings.Repeat("0", 40)+`","files":[{"name":"a.txt","delete":true}]}`)
edited := a.want(200, "PATCH", "/api/gists/"+id, gt, `{"files":[{"name":"a.txt","delete":true},{"name":"b.txt","rename":"c.txt"},{"name":"d.txt","content":"four"}]}`)
var names []string
for _, f := range edited["files"].([]any) {
names = append(names, f.(map[string]any)["name"].(string))
}
if strings.Join(names, ",") != "c.txt,d.txt" {
t.Errorf("gist files: %v", names)
}
if v := a.want(200, "GET", "/api/gists/"+id+"/revisions", "", ""); v["total"].(float64) != 2 {
t.Errorf("revisions: %v", v)
}
if v := a.want(200, "GET", "/api/gists/"+id+"/revisions?limit=1", "", ""); v["next"] == nil || len(v["items"].([]any)) != 1 {
t.Errorf("revision paging: %v", v)
}
if v := a.want(200, "GET", "/api/gists?from=9223372036854775807", "", ""); len(v["items"].([]any)) != 0 {
t.Errorf("far page: %v", v)
}
a.want(204, "PUT", "/api/gists/"+id+"/star", bt, "")
a.want(201, "POST", "/api/gists/"+id+"/comments", bt, `{"body":"neat"}`)
a.want(201, "POST", "/api/gists/"+id+"/forks", bt, "")
if v := a.want(200, "GET", "/api/search?kind=gists&q=notes", "", ""); len(items(v)) != 2 {
t.Errorf("gist search: %v", v)
}
if v := a.want(200, "GET", "/api/admin/gists", at, ""); v["total"].(float64) != 2 {
t.Errorf("admin gists: %v", v)
}
if w := a.do("POST", "/api/gists?name=raw.txt&visibility=secret", gt, "plain body"); w.Code != 201 || !strings.HasPrefix(w.Body.String(), "https://example.org/gist/") {
t.Errorf("raw gist: %d %s", w.Code, w.Body.String())
}
a.want(204, "DELETE", "/api/gists/"+id, gt, "")
}
func jsonID(v map[string]any) string {
b, _ := json.Marshal(v["id"])
return string(b)
}
|