krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: 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}