krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: e2e/api_test.go · raw

  1package e2e
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"fmt"
  7	"io"
  8	"net/http"
  9	"strings"
 10	"testing"
 11	"time"
 12)
 13
 14// apiCall posts one command to the JSON API.
 15func (i *instance) apiCall(t *testing.T, token string, argv []string, stdin string) (int, map[string]any) {
 16	t.Helper()
 17	body, _ := json.Marshal(map[string]any{"argv": argv, "stdin": stdin})
 18	req, err := http.NewRequest("POST",
 19		fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", i.httpPort), bytes.NewReader(body))
 20	if err != nil {
 21		t.Fatal(err)
 22	}
 23	if token != "" {
 24		req.Header.Set("Authorization", "Bearer "+token)
 25	}
 26	resp, err := http.DefaultClient.Do(req)
 27	if err != nil {
 28		t.Fatal(err)
 29	}
 30	defer resp.Body.Close()
 31	raw, _ := io.ReadAll(resp.Body)
 32	var out map[string]any
 33	if err := json.Unmarshal(raw, &out); err != nil {
 34		t.Fatalf("API response not JSON (%d): %s", resp.StatusCode, raw)
 35	}
 36	return resp.StatusCode, out
 37}
 38
 39func TestJSONAPI(t *testing.T) {
 40	inst := startInstanceWith(t, "[api]\nenabled = true\n")
 41	aliceKey := inst.newKey(t, "alice")
 42	inst.admin(t, "admin", "user", "create", "alice",
 43		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 44
 45	// Tokens are minted over SSH, shown once.
 46	out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--json")
 47	if code != 0 {
 48		t.Fatalf("token create: %s", errOut)
 49	}
 50	var env struct {
 51		Data struct {
 52			Token string `json:"token"`
 53		} `json:"data"`
 54	}
 55	if err := json.Unmarshal([]byte(out), &env); err != nil || !strings.HasPrefix(env.Data.Token, "gb_") {
 56		t.Fatalf("token create output: %v %s", err, out)
 57	}
 58	token := env.Data.Token
 59
 60	// Auth failures are uniform 401s.
 61	if status, _ := inst.apiCall(t, "", []string{"whoami"}, ""); status != 401 {
 62		t.Fatalf("no token: %d", status)
 63	}
 64	if status, _ := inst.apiCall(t, "gb_wrong", []string{"whoami"}, ""); status != 401 {
 65		t.Fatalf("bad token: %d", status)
 66	}
 67
 68	// whoami through the API: same envelope, exit_code injected.
 69	status, body := inst.apiCall(t, token, []string{"whoami"}, "")
 70	if status != 200 || body["exit_code"].(float64) != 0 {
 71		t.Fatalf("whoami: %d %v", status, body)
 72	}
 73	if data := body["data"].(map[string]any); data["username"] != "alice" {
 74		t.Fatalf("whoami data: %v", body)
 75	}
 76
 77	// Mutations work: create a repo and an issue, then read it back.
 78	if status, body = inst.apiCall(t, token, []string{"repo", "create", "alice/proj", "--private"}, ""); status != 200 {
 79		t.Fatalf("repo create: %d %v", status, body)
 80	}
 81	if status, _ = inst.apiCall(t, token, []string{"issue", "create", "alice/proj", "--title", "from the api", "--file", "-"}, "body via stdin\n"); status != 200 {
 82		t.Fatal("issue create failed")
 83	}
 84	status, body = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "1"}, "")
 85	data := body["data"].(map[string]any)
 86	if status != 200 || data["title"] != "from the api" || data["body"] != "body via stdin\n" {
 87		t.Fatalf("issue show: %d %v", status, body)
 88	}
 89
 90	// Exit codes map to HTTP statuses.
 91	if status, _ = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "99"}, ""); status != 404 {
 92		t.Fatalf("missing issue: %d", status)
 93	}
 94	if status, _ = inst.apiCall(t, token, []string{"nonsense"}, ""); status != 400 {
 95		t.Fatalf("unknown command: %d", status)
 96	}
 97
 98	// Raw-output commands (no envelope) are wrapped.
 99	status, body = inst.apiCall(t, token, []string{"help"}, "")
100	if status != 200 || !strings.Contains(body["output"].(string), "repo create") {
101		t.Fatalf("help via API: %d %v", status, body)
102	}
103
104	// Git transport is refused by name.
105	if status, _ = inst.apiCall(t, token, []string{"git-upload-pack", "alice/proj"}, ""); status != 400 {
106		t.Fatalf("git over API: %d", status)
107	}
108
109	// Token management never works over the API: no credential minting.
110	status, body = inst.apiCall(t, token, []string{"token", "create", "--name", "sneaky"}, "")
111	if status != 403 || !strings.Contains(body["error"].(string), "only available over SSH") {
112		t.Fatalf("token create via API: %d %v", status, body)
113	}
114
115	// Read-scoped tokens read but never write.
116	out, _, code = inst.ssh(t, aliceKey, "", "token", "create", "--name", "reader", "--scope", "read", "--json")
117	if code != 0 {
118		t.Fatal("read token create failed")
119	}
120	json.Unmarshal([]byte(out), &env)
121	readToken := env.Data.Token
122	if status, _ = inst.apiCall(t, readToken, []string{"issue", "list", "alice/proj"}, ""); status != 200 {
123		t.Fatalf("read token list: %d", status)
124	}
125	status, body = inst.apiCall(t, readToken, []string{"issue", "close", "alice/proj", "1"}, "")
126	if status != 403 || !strings.Contains(body["error"].(string), "read-only") {
127		t.Fatalf("read token write: %d %v", status, body)
128	}
129
130	// Expiry: a 1-second token dies.
131	out, _, _ = inst.ssh(t, aliceKey, "", "token", "create", "--name", "brief", "--ttl", "1s", "--json")
132	json.Unmarshal([]byte(out), &env)
133	brief := env.Data.Token
134	if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 {
135		t.Fatal("fresh short-ttl token rejected")
136	}
137	time.Sleep(1100 * time.Millisecond)
138	if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 401 {
139		t.Fatal("expired token accepted")
140	}
141
142	// Revocation kills a token immediately.
143	if _, _, code = inst.ssh(t, aliceKey, "", "token", "revoke", "ci"); code != 0 {
144		t.Fatal("revoke failed")
145	}
146	if status, _ = inst.apiCall(t, token, []string{"whoami"}, ""); status != 401 {
147		t.Fatal("revoked token accepted")
148	}
149
150	// With [api] disabled (the default), the endpoint does not exist.
151	inst2 := startInstance(t)
152	req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", inst2.httpPort),
153		strings.NewReader(`{"argv":["whoami"]}`))
154	req.Header.Set("Authorization", "Bearer gb_x")
155	resp, err := http.DefaultClient.Do(req)
156	if err != nil {
157		t.Fatal(err)
158	}
159	resp.Body.Close()
160	if resp.StatusCode != 404 {
161		t.Fatalf("API on disabled instance: %d, want 404", resp.StatusCode)
162	}
163}