e2e/api_test.go

v1.37.0
gitbay/e2e/api_test.go history · blame · raw

388 lines · 14326 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	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}