e2e/api_test.go

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

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}