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