e2e/api_test.go

f327db6192d9a0877606a40385b24c0cda29fd2d
gitbay/e2e/api_test.go history · blame · raw

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}