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