e2e/api_test.go

4effb29e572777a68b56b21da1b1733fb6219e89
gitbay/e2e/api_test.go history · blame · raw

383 lines · 14183 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 never works over the API: no credential minting.
143	status, body = inst.apiCall(t, token, []string{"token", "create", "--name", "sneaky"}, "")
144	if status != 403 || !strings.Contains(body["error"].(string), "only available over SSH") {
145		t.Fatalf("token create via API: %d %v", status, body)
146	}
147
148	// Read-scoped tokens read but never write.
149	out, _, code = inst.ssh(t, aliceKey, "", "token", "create", "--name", "reader", "--scope", "read", "--json")
150	if code != 0 {
151		t.Fatal("read token create failed")
152	}
153	json.Unmarshal([]byte(out), &env)
154	readToken := env.Data.Token
155	if status, _ = inst.apiCall(t, readToken, []string{"issue", "list", "alice/proj"}, ""); status != 200 {
156		t.Fatalf("read token list: %d", status)
157	}
158	status, body = inst.apiCall(t, readToken, []string{"issue", "close", "alice/proj", "1"}, "")
159	if status != 403 || !strings.Contains(body["error"].(string), "read-only") {
160		t.Fatalf("read token write: %d %v", status, body)
161	}
162
163	// Expiry: a 1-second token dies.
164	out, _, _ = inst.ssh(t, aliceKey, "", "token", "create", "--name", "brief", "--ttl", "1s", "--json")
165	json.Unmarshal([]byte(out), &env)
166	brief := env.Data.Token
167	if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 {
168		t.Fatal("fresh short-ttl token rejected")
169	}
170	time.Sleep(1100 * time.Millisecond)
171	if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 401 {
172		t.Fatal("expired token accepted")
173	}
174
175	// Revocation kills a token immediately.
176	if _, _, code = inst.ssh(t, aliceKey, "", "token", "revoke", "ci"); code != 0 {
177		t.Fatal("revoke failed")
178	}
179	if status, _ = inst.apiCall(t, token, []string{"whoami"}, ""); status != 401 {
180		t.Fatal("revoked token accepted")
181	}
182
183	// With [api] disabled (the default), the endpoint does not exist.
184	inst2 := startInstance(t)
185	req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", inst2.httpPort),
186		strings.NewReader(`{"argv":["whoami"]}`))
187	req.Header.Set("Authorization", "Bearer gb_x")
188	resp, err := http.DefaultClient.Do(req)
189	if err != nil {
190		t.Fatal(err)
191	}
192	resp.Body.Close()
193	if resp.StatusCode != 404 {
194		t.Fatalf("API on disabled instance: %d, want 404", resp.StatusCode)
195	}
196}
197
198// TestAPIRateLimit covers the limiter over the wire: a caller who exceeds
199// their budget gets 429 with a Retry-After a client can honour, writes are
200// metered separately from reads, and one caller cannot spend another's
201// budget.
202func TestAPIRateLimit(t *testing.T) {
203	// 6/minute sustained, so the read burst is 6 and the write burst 0.6 —
204	// the first write is allowed and the second is not.
205	inst := startInstanceWith(t, "[api]\nenabled = true\n[limits]\napi_rate = 6\n")
206	aliceKey := inst.newKey(t, "alice")
207	bobKey := inst.newKey(t, "bob")
208	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
209	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
210	aliceTok := mintToken(t, inst, aliceKey, "alice-app")
211	bobTok := mintToken(t, inst, bobKey, "bob-app")
212
213	// Reads: the burst is spendable, then the door closes.
214	var limited bool
215	for i := 0; i < 12; i++ {
216		status, body := inst.apiCall(t, aliceTok, []string{"whoami"}, "")
217		if status == http.StatusTooManyRequests {
218			limited = true
219			if msg, _ := body["error"].(string); !strings.Contains(msg, "retry") {
220				t.Errorf("429 body does not say when to retry: %v", body)
221			}
222			break
223		}
224	}
225	if !limited {
226		t.Fatal("a caller never hit the rate limit")
227	}
228
229	// The 429 carries Retry-After, so a client backs off correctly instead
230	// of hammering.
231	req, _ := http.NewRequest("POST",
232		fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", inst.httpPort),
233		strings.NewReader(`{"argv":["whoami"]}`))
234	req.Header.Set("Authorization", "Bearer "+aliceTok)
235	resp, err := http.DefaultClient.Do(req)
236	if err != nil {
237		t.Fatal(err)
238	}
239	resp.Body.Close()
240	if resp.StatusCode != http.StatusTooManyRequests {
241		t.Fatalf("expected a second 429, got %d", resp.StatusCode)
242	}
243	if ra := resp.Header.Get("Retry-After"); ra == "" || ra == "0" {
244		t.Errorf("Retry-After = %q", ra)
245	}
246
247	// One caller's flood does not spend another's budget.
248	if status, _ := inst.apiCall(t, bobTok, []string{"whoami"}, ""); status != http.StatusOK {
249		t.Errorf("bob was limited by alice's traffic: %d", status)
250	}
251
252	// Writes are metered separately: bob's read budget is nearly full, but
253	// his write budget is not.
254	inst.apiCall(t, bobTok, []string{"repo", "create", "bob/one"}, "")
255	status, _ := inst.apiCall(t, bobTok, []string{"repo", "create", "bob/two"}, "")
256	if status != http.StatusTooManyRequests {
257		t.Errorf("second write status %d, want 429 from the write budget", status)
258	}
259}
260
261// mintToken creates an API token over SSH and returns its value.
262func mintToken(t *testing.T, inst *instance, key, name string) string {
263	t.Helper()
264	out, errOut, code := inst.ssh(t, key, "", "token", "create", "--name", name, "--json")
265	if code != 0 {
266		t.Fatalf("token create: %s", errOut)
267	}
268	var env struct {
269		Data struct {
270			Token string `json:"token"`
271		} `json:"data"`
272	}
273	if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.Token == "" {
274		t.Fatalf("token JSON: %v\n%s", err, out)
275	}
276	return env.Data.Token
277}
278
279// apiGet fetches one read command, optionally conditionally.
280func (i *instance) apiGet(t *testing.T, token string, argv []string, ifNoneMatch string) (int, string, string) {
281	t.Helper()
282	q := url.Values{}
283	for _, a := range argv {
284		q.Add("argv", a)
285	}
286	req, err := http.NewRequest("GET",
287		fmt.Sprintf("http://127.0.0.1:%d/api/v1/read?%s", i.httpPort, q.Encode()), nil)
288	if err != nil {
289		t.Fatal(err)
290	}
291	if token != "" {
292		req.Header.Set("Authorization", "Bearer "+token)
293	}
294	if ifNoneMatch != "" {
295		req.Header.Set("If-None-Match", ifNoneMatch)
296	}
297	resp, err := http.DefaultClient.Do(req)
298	if err != nil {
299		t.Fatal(err)
300	}
301	defer resp.Body.Close()
302	raw, _ := io.ReadAll(resp.Body)
303	return resp.StatusCode, resp.Header.Get("ETag"), string(raw)
304}
305
306// TestAPIReadGET covers the conditional-request surface: reads over GET
307// with an ETag, 304 on revalidation, writes refused, and one caller's ETag
308// never matching another's.
309func TestAPIReadGET(t *testing.T) {
310	inst := startInstanceWith(t, "[api]\nenabled = true\n")
311	aliceKey := inst.newKey(t, "alice")
312	bobKey := inst.newKey(t, "bob")
313	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
314	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
315	aliceTok := mintToken(t, inst, aliceKey, "alice-get")
316	bobTok := mintToken(t, inst, bobKey, "bob-get")
317	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
318		t.Fatalf("repo create: %s", errOut)
319	}
320
321	status, etag, body := inst.apiGet(t, aliceTok, []string{"repo", "show", "alice/app"}, "")
322	if status != 200 {
323		t.Fatalf("GET read: %d %s", status, body)
324	}
325	if etag == "" {
326		t.Fatal("no ETag, so a client cannot revalidate")
327	}
328	if !strings.Contains(body, `"alice/app"`) {
329		t.Errorf("body: %s", body)
330	}
331
332	// Revalidation returns 304 with no body — the point of the surface.
333	status, _, body = inst.apiGet(t, aliceTok, []string{"repo", "show", "alice/app"}, etag)
334	if status != http.StatusNotModified {
335		t.Fatalf("revalidation status %d, want 304", status)
336	}
337	if body != "" {
338		t.Errorf("304 carried a body: %q", body)
339	}
340	// A weak validator from an intermediary still matches.
341	if status, _, _ := inst.apiGet(t, aliceTok, []string{"repo", "show", "alice/app"}, "W/"+etag); status != http.StatusNotModified {
342		t.Errorf("weak ETag not honoured: %d", status)
343	}
344
345	// A stale ETag gets the real body back, not a 304.
346	if status, _, body := inst.apiGet(t, aliceTok, []string{"repo", "show", "alice/app"}, `"stale"`); status != 200 || body == "" {
347		t.Errorf("stale ETag: %d %q", status, body)
348	}
349
350	// The ETag is salted per caller, so one account can never be handed a
351	// 304 for another account's cached answer.
352	if status, _, _ := inst.apiGet(t, bobTok, []string{"repo", "show", "alice/app"}, etag); status == http.StatusNotModified {
353		t.Error("another caller's ETag matched")
354	}
355
356	// Responses must not be storable by shared caches.
357	req, _ := http.NewRequest("GET",
358		fmt.Sprintf("http://127.0.0.1:%d/api/v1/read?argv=whoami", inst.httpPort), nil)
359	req.Header.Set("Authorization", "Bearer "+aliceTok)
360	resp, err := http.DefaultClient.Do(req)
361	if err != nil {
362		t.Fatal(err)
363	}
364	resp.Body.Close()
365	if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "private") {
366		t.Errorf("Cache-Control = %q, want private", cc)
367	}
368
369	// A GET can never mutate: writes are refused by the registry's own
370	// ReadOnly flag rather than by a hand-kept list.
371	status, _, body = inst.apiGet(t, aliceTok, []string{"repo", "create", "alice/sneaky"}, "")
372	if status != http.StatusBadRequest || !strings.Contains(body, "POST it") {
373		t.Fatalf("write over GET: %d %s", status, body)
374	}
375	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/sneaky"); code == 0 {
376		t.Fatal("a GET created a repository")
377	}
378
379	// Unauthenticated reads are refused like everywhere else.
380	if status, _, _ := inst.apiGet(t, "", []string{"whoami"}, ""); status != http.StatusUnauthorized {
381		t.Errorf("anonymous GET status %d, want 401", status)
382	}
383}