e2e/api_test.go
388 lines · 14326 bytes
6 symbols in this file
1package e2e
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15)
16
17// apiCall posts one command to the JSON API.
18func (i *instance) apiCall(t *testing.T, token string, argv []string, stdin string) (int, map[string]any) {
19 t.Helper()
20 body, _ := json.Marshal(map[string]any{"argv": argv, "stdin": stdin})
21 req, err := http.NewRequest("POST",
22 fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", i.httpPort), bytes.NewReader(body))
23 if err != nil {
24 t.Fatal(err)
25 }
26 if token != "" {
27 req.Header.Set("Authorization", "Bearer "+token)
28 }
29 resp, err := http.DefaultClient.Do(req)
30 if err != nil {
31 t.Fatal(err)
32 }
33 defer resp.Body.Close()
34 raw, _ := io.ReadAll(resp.Body)
35 var out map[string]any
36 if err := json.Unmarshal(raw, &out); err != nil {
37 t.Fatalf("API response not JSON (%d): %s", resp.StatusCode, raw)
38 }
39 return resp.StatusCode, out
40}
41
42func TestJSONAPI(t *testing.T) {
43 t.Parallel()
44 inst := startInstanceWith(t, "[api]\nenabled = true\n")
45 aliceKey := inst.newKey(t, "alice")
46 inst.admin(t, "admin", "user", "create", "alice",
47 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
48
49 // Tokens are minted over SSH, shown once.
50 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--scope", "full", "--json")
51 if code != 0 {
52 t.Fatalf("token create: %s", errOut)
53 }
54 var env struct {
55 Data struct {
56 Token string `json:"token"`
57 } `json:"data"`
58 }
59 if err := json.Unmarshal([]byte(out), &env); err != nil || !strings.HasPrefix(env.Data.Token, "gb_") {
60 t.Fatalf("token create output: %v %s", err, out)
61 }
62 token := env.Data.Token
63
64 // Auth failures are uniform 401s.
65 if status, _ := inst.apiCall(t, "", []string{"whoami"}, ""); status != 401 {
66 t.Fatalf("no token: %d", status)
67 }
68 if status, _ := inst.apiCall(t, "gb_wrong", []string{"whoami"}, ""); status != 401 {
69 t.Fatalf("bad token: %d", status)
70 }
71
72 // whoami through the API: same envelope, exit_code injected.
73 status, body := inst.apiCall(t, token, []string{"whoami"}, "")
74 if status != 200 || body["exit_code"].(float64) != 0 {
75 t.Fatalf("whoami: %d %v", status, body)
76 }
77 if data := body["data"].(map[string]any); data["username"] != "alice" {
78 t.Fatalf("whoami data: %v", body)
79 }
80
81 // Mutations work: create a repo and an issue, then read it back.
82 if status, body = inst.apiCall(t, token, []string{"repo", "create", "alice/proj", "--private"}, ""); status != 200 {
83 t.Fatalf("repo create: %d %v", status, body)
84 }
85 if status, _ = inst.apiCall(t, token, []string{"issue", "create", "alice/proj", "--title", "from the api", "--file", "-"}, "body via stdin\n"); status != 200 {
86 t.Fatal("issue create failed")
87 }
88 status, body = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "1"}, "")
89 data := body["data"].(map[string]any)
90 if status != 200 || data["title"] != "from the api" || data["body"] != "body via stdin\n" {
91 t.Fatalf("issue show: %d %v", status, body)
92 }
93
94 // Exit codes map to HTTP statuses.
95 if status, _ = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "99"}, ""); status != 404 {
96 t.Fatalf("missing issue: %d", status)
97 }
98 if status, _ = inst.apiCall(t, token, []string{"nonsense"}, ""); status != 400 {
99 t.Fatalf("unknown command: %d", status)
100 }
101
102 // Raw-output commands (no envelope) are wrapped. Reading a file is the
103 // cheapest raw output, so give the repo one commit to read from.
104 work := t.TempDir()
105 aliceEnv := inst.gitEnv(aliceKey)
106 mustGit(t, work, aliceEnv, "clone", inst.sshURL("alice/proj"), "proj")
107 dir := filepath.Join(work, "proj")
108 if err := os.WriteFile(filepath.Join(dir, "README"), []byte("plain text, not json\n"), 0o644); err != nil {
109 t.Fatal(err)
110 }
111 mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
112 mustGit(t, dir, aliceEnv, "add", "README")
113 mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "init")
114 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
115
116 // A tarball is not an envelope, so it comes back under "output".
117 status, body = inst.apiCall(t, token, []string{"repo", "download", "alice/proj"}, "")
118 raw, isRaw := body["output"].(string)
119 if status != 200 || !isRaw || raw == "" {
120 t.Fatalf("repo download via API: %d %v", status, body)
121 }
122
123 // help answers with the registry as data, so a consumer can read one
124 // command's arguments without scraping the whole listing.
125 status, body = inst.apiCall(t, token, []string{"help", "issue", "create"}, "")
126 if status != 200 {
127 t.Fatalf("help via API: %d %v", status, body)
128 }
129 rows, ok := body["data"].([]any)
130 if !ok || len(rows) != 1 {
131 t.Fatalf("help issue create: %v", body)
132 }
133 row := rows[0].(map[string]any)
134 if row["path"] != "issue create" || !strings.Contains(row["usage"].(string), "--title") {
135 t.Fatalf("help issue create row: %v", row)
136 }
137
138 // Git transport is refused by name.
139 if status, _ = inst.apiCall(t, token, []string{"git-upload-pack", "alice/proj"}, ""); status != 400 {
140 t.Fatalf("git over API: %d", status)
141 }
142
143 // Token management works over the API like everything else: no
144 // command is held back from a surface any more (#234). A full-scope
145 // token mints another, which is what a full-scope credential means.
146 status, body = inst.apiCall(t, token, []string{"token", "create", "--name", "minted"}, "")
147 if status != 200 {
148 t.Fatalf("token create via API: %d %v", status, body)
149 }
150
151 // Read-scoped tokens read but never write.
152 out, _, code = inst.ssh(t, aliceKey, "", "token", "create", "--name", "reader", "--scope", "read", "--json")
153 if code != 0 {
154 t.Fatal("read token create failed")
155 }
156 json.Unmarshal([]byte(out), &env)
157 readToken := env.Data.Token
158 if status, _ = inst.apiCall(t, readToken, []string{"issue", "list", "alice/proj"}, ""); status != 200 {
159 t.Fatalf("read token list: %d", status)
160 }
161 status, body = inst.apiCall(t, readToken, []string{"issue", "close", "alice/proj", "1"}, "")
162 if status != 403 || !strings.Contains(body["error"].(string), "read-only") {
163 t.Fatalf("read token write: %d %v", status, body)
164 }
165
166 // Expiry: a 1-second token dies.
167 out, _, _ = inst.ssh(t, aliceKey, "", "token", "create", "--name", "brief", "--ttl", "1s", "--json")
168 json.Unmarshal([]byte(out), &env)
169 brief := env.Data.Token
170 if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 {
171 t.Fatal("fresh short-ttl token rejected")
172 }
173 time.Sleep(1100 * time.Millisecond)
174 if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 401 {
175 t.Fatal("expired token accepted")
176 }
177
178 // Revocation kills a token immediately.
179 if _, _, code = inst.ssh(t, aliceKey, "", "token", "revoke", "ci"); code != 0 {
180 t.Fatal("revoke failed")
181 }
182 if status, _ = inst.apiCall(t, token, []string{"whoami"}, ""); status != 401 {
183 t.Fatal("revoked token accepted")
184 }
185
186 // With [api] disabled (the default), the endpoint does not exist.
187 inst2 := startInstance(t)
188 req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", inst2.httpPort),
189 strings.NewReader(`{"argv":["whoami"]}`))
190 req.Header.Set("Authorization", "Bearer gb_x")
191 resp, err := http.DefaultClient.Do(req)
192 if err != nil {
193 t.Fatal(err)
194 }
195 resp.Body.Close()
196 if resp.StatusCode != 404 {
197 t.Fatalf("API on disabled instance: %d, want 404", resp.StatusCode)
198 }
199}
200
201// TestAPIRateLimit covers the limiter over the wire: a caller who exceeds
202// their budget gets 429 with a Retry-After a client can honour, writes are
203// metered separately from reads, and one caller cannot spend another's
204// budget.
205func TestAPIRateLimit(t *testing.T) {
206 t.Parallel()
207 // 6/minute sustained, so the read burst is 6 and the write burst 0.6 —
208 // the first write is allowed and the second is not.
209 inst := startInstanceWith(t, "[api]\nenabled = true\n[limits]\napi_rate = 6\n")
210 aliceKey := inst.newKey(t, "alice")
211 bobKey := inst.newKey(t, "bob")
212 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
213 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
214 aliceTok := mintToken(t, inst, aliceKey, "alice-app")
215 bobTok := mintToken(t, inst, bobKey, "bob-app")
216
217 // Reads: the burst is spendable, then the door closes.
218 var limited bool
219 for i := 0; i < 12; i++ {
220 status, body := inst.apiCall(t, aliceTok, []string{"whoami"}, "")
221 if status == http.StatusTooManyRequests {
222 limited = true
223 if msg, _ := body["error"].(string); !strings.Contains(msg, "retry") {
224 t.Errorf("429 body does not say when to retry: %v", body)
225 }
226 break
227 }
228 }
229 if !limited {
230 t.Fatal("a caller never hit the rate limit")
231 }
232
233 // The 429 carries Retry-After, so a client backs off correctly instead
234 // of hammering.
235 req, _ := http.NewRequest("POST",
236 fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", inst.httpPort),
237 strings.NewReader(`{"argv":["whoami"]}`))
238 req.Header.Set("Authorization", "Bearer "+aliceTok)
239 resp, err := http.DefaultClient.Do(req)
240 if err != nil {
241 t.Fatal(err)
242 }
243 resp.Body.Close()
244 if resp.StatusCode != http.StatusTooManyRequests {
245 t.Fatalf("expected a second 429, got %d", resp.StatusCode)
246 }
247 if ra := resp.Header.Get("Retry-After"); ra == "" || ra == "0" {
248 t.Errorf("Retry-After = %q", ra)
249 }
250
251 // One caller's flood does not spend another's budget.
252 if status, _ := inst.apiCall(t, bobTok, []string{"whoami"}, ""); status != http.StatusOK {
253 t.Errorf("bob was limited by alice's traffic: %d", status)
254 }
255
256 // Writes are metered separately: bob's read budget is nearly full, but
257 // his write budget is not.
258 inst.apiCall(t, bobTok, []string{"repo", "create", "bob/one"}, "")
259 status, _ := inst.apiCall(t, bobTok, []string{"repo", "create", "bob/two"}, "")
260 if status != http.StatusTooManyRequests {
261 t.Errorf("second write status %d, want 429 from the write budget", status)
262 }
263}
264
265// mintToken creates an API token over SSH and returns its value.
266func mintToken(t *testing.T, inst *instance, key, name string) string {
267 t.Helper()
268 out, errOut, code := inst.ssh(t, key, "", "token", "create", "--name", name, "--scope", "full", "--json")
269 if code != 0 {
270 t.Fatalf("token create: %s", errOut)
271 }
272 var env struct {
273 Data struct {
274 Token string `json:"token"`
275 } `json:"data"`
276 }
277 if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.Token == "" {
278 t.Fatalf("token JSON: %v\n%s", err, out)
279 }
280 return env.Data.Token
281}
282
283// apiGet fetches one read command, optionally conditionally.
284func (i *instance) apiGet(t *testing.T, token string, argv []string, ifNoneMatch string) (int, string, string) {
285 t.Helper()
286 q := url.Values{}
287 for _, a := range argv {
288 q.Add("argv", a)
289 }
290 req, err := http.NewRequest("GET",
291 fmt.Sprintf("http://127.0.0.1:%d/api/v1/read?%s", i.httpPort, q.Encode()), nil)
292 if err != nil {
293 t.Fatal(err)
294 }
295 if token != "" {
296 req.Header.Set("Authorization", "Bearer "+token)
297 }
298 if ifNoneMatch != "" {
299 req.Header.Set("If-None-Match", ifNoneMatch)
300 }
301 resp, err := http.DefaultClient.Do(req)
302 if err != nil {
303 t.Fatal(err)
304 }
305 defer resp.Body.Close()
306 raw, _ := io.ReadAll(resp.Body)
307 return resp.StatusCode, resp.Header.Get("ETag"), string(raw)
308}
309
310// TestAPIReadGET covers the conditional-request surface: reads over GET
311// with an ETag, 304 on revalidation, writes refused, and one caller's ETag
312// never matching another's.
313func TestAPIReadGET(t *testing.T) {
314 t.Parallel()
315 inst := startInstanceWith(t, "[api]\nenabled = true\n")
316 aliceKey := inst.newKey(t, "alice")
317 bobKey := inst.newKey(t, "bob")
318 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
319 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
320 aliceTok := mintToken(t, inst, aliceKey, "alice-get")
321 bobTok := mintToken(t, inst, bobKey, "bob-get")
322 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
323 t.Fatalf("repo create: %s", errOut)
324 }
325
326 status, etag, body := inst.apiGet(t, aliceTok, []string{"repo", "show", "alice/app"}, "")
327 if status != 200 {
328 t.Fatalf("GET read: %d %s", status, body)
329 }
330 if etag == "" {
331 t.Fatal("no ETag, so a client cannot revalidate")
332 }
333 if !strings.Contains(body, `"alice/app"`) {
334 t.Errorf("body: %s", body)
335 }
336
337 // Revalidation returns 304 with no body — the point of the surface.
338 status, _, body = inst.apiGet(t, aliceTok, []string{"repo", "show", "alice/app"}, etag)
339 if status != http.StatusNotModified {
340 t.Fatalf("revalidation status %d, want 304", status)
341 }
342 if body != "" {
343 t.Errorf("304 carried a body: %q", body)
344 }
345 // A weak validator from an intermediary still matches.
346 if status, _, _ := inst.apiGet(t, aliceTok, []string{"repo", "show", "alice/app"}, "W/"+etag); status != http.StatusNotModified {
347 t.Errorf("weak ETag not honoured: %d", status)
348 }
349
350 // A stale ETag gets the real body back, not a 304.
351 if status, _, body := inst.apiGet(t, aliceTok, []string{"repo", "show", "alice/app"}, `"stale"`); status != 200 || body == "" {
352 t.Errorf("stale ETag: %d %q", status, body)
353 }
354
355 // The ETag is salted per caller, so one account can never be handed a
356 // 304 for another account's cached answer.
357 if status, _, _ := inst.apiGet(t, bobTok, []string{"repo", "show", "alice/app"}, etag); status == http.StatusNotModified {
358 t.Error("another caller's ETag matched")
359 }
360
361 // Responses must not be storable by shared caches.
362 req, _ := http.NewRequest("GET",
363 fmt.Sprintf("http://127.0.0.1:%d/api/v1/read?argv=whoami", inst.httpPort), nil)
364 req.Header.Set("Authorization", "Bearer "+aliceTok)
365 resp, err := http.DefaultClient.Do(req)
366 if err != nil {
367 t.Fatal(err)
368 }
369 resp.Body.Close()
370 if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "private") {
371 t.Errorf("Cache-Control = %q, want private", cc)
372 }
373
374 // A GET can never mutate: writes are refused by the registry's own
375 // ReadOnly flag rather than by a hand-kept list.
376 status, _, body = inst.apiGet(t, aliceTok, []string{"repo", "create", "alice/sneaky"}, "")
377 if status != http.StatusBadRequest || !strings.Contains(body, "POST it") {
378 t.Fatalf("write over GET: %d %s", status, body)
379 }
380 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/sneaky"); code == 0 {
381 t.Fatal("a GET created a repository")
382 }
383
384 // Unauthenticated reads are refused like everywhere else.
385 if status, _, _ := inst.apiGet(t, "", []string{"whoami"}, ""); status != http.StatusUnauthorized {
386 t.Errorf("anonymous GET status %d, want 401", status)
387 }
388}