ScuttleBot

scuttlebot / internal / api / api_test.go
Blame History Raw 269 lines
1
package api_test
2
3
import (
4
"bytes"
5
"encoding/json"
6
"fmt"
7
"net/http"
8
"net/http/httptest"
9
"sync"
10
"testing"
11
12
"github.com/conflicthq/scuttlebot/internal/api"
13
"github.com/conflicthq/scuttlebot/internal/auth"
14
"github.com/conflicthq/scuttlebot/internal/registry"
15
"log/slog"
16
"os"
17
)
18
19
var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
20
21
// mockProvisioner for registry tests.
22
type mockProvisioner struct {
23
mu sync.Mutex
24
accounts map[string]string
25
}
26
27
func newMock() *mockProvisioner {
28
return &mockProvisioner{accounts: make(map[string]string)}
29
}
30
31
func (m *mockProvisioner) RegisterAccount(name, pass string) error {
32
m.mu.Lock()
33
defer m.mu.Unlock()
34
if _, ok := m.accounts[name]; ok {
35
return fmt.Errorf("ACCOUNT_EXISTS")
36
}
37
m.accounts[name] = pass
38
return nil
39
}
40
41
func (m *mockProvisioner) ChangePassword(name, pass string) error {
42
m.mu.Lock()
43
defer m.mu.Unlock()
44
if _, ok := m.accounts[name]; !ok {
45
return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
46
}
47
m.accounts[name] = pass
48
return nil
49
}
50
51
const testToken = "test-api-token-abc123"
52
53
func newTestServer(t *testing.T) *httptest.Server {
54
t.Helper()
55
reg := registry.New(newMock(), []byte("test-signing-key"))
56
srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
57
return httptest.NewServer(srv.Handler())
58
}
59
60
func authHeader() http.Header {
61
h := http.Header{}
62
h.Set("Authorization", "Bearer "+testToken)
63
return h
64
}
65
66
func do(t *testing.T, srv *httptest.Server, method, path string, body any, headers http.Header) *http.Response {
67
t.Helper()
68
var buf bytes.Buffer
69
if body != nil {
70
if err := json.NewEncoder(&buf).Encode(body); err != nil {
71
t.Fatalf("encode body: %v", err)
72
}
73
}
74
req, err := http.NewRequest(method, srv.URL+path, &buf)
75
if err != nil {
76
t.Fatalf("new request: %v", err)
77
}
78
req.Header.Set("Content-Type", "application/json")
79
for k, vs := range headers {
80
for _, v := range vs {
81
req.Header.Set(k, v)
82
}
83
}
84
resp, err := http.DefaultClient.Do(req)
85
if err != nil {
86
t.Fatalf("do request: %v", err)
87
}
88
return resp
89
}
90
91
func TestAuthRequired(t *testing.T) {
92
srv := newTestServer(t)
93
defer srv.Close()
94
95
endpoints := []struct{ method, path string }{
96
{"GET", "/v1/status"},
97
{"GET", "/v1/agents"},
98
{"POST", "/v1/agents/register"},
99
}
100
for _, e := range endpoints {
101
t.Run(e.method+" "+e.path, func(t *testing.T) {
102
resp := do(t, srv, e.method, e.path, nil, nil)
103
defer resp.Body.Close()
104
if resp.StatusCode != http.StatusUnauthorized {
105
t.Errorf("expected 401, got %d", resp.StatusCode)
106
}
107
})
108
}
109
}
110
111
func TestInvalidToken(t *testing.T) {
112
srv := newTestServer(t)
113
defer srv.Close()
114
115
h := http.Header{}
116
h.Set("Authorization", "Bearer wrong-token")
117
resp := do(t, srv, "GET", "/v1/status", nil, h)
118
defer resp.Body.Close()
119
if resp.StatusCode != http.StatusUnauthorized {
120
t.Errorf("expected 401, got %d", resp.StatusCode)
121
}
122
}
123
124
func TestStatus(t *testing.T) {
125
srv := newTestServer(t)
126
defer srv.Close()
127
128
resp := do(t, srv, "GET", "/v1/status", nil, authHeader())
129
defer resp.Body.Close()
130
if resp.StatusCode != http.StatusOK {
131
t.Errorf("expected 200, got %d", resp.StatusCode)
132
}
133
134
var body map[string]any
135
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
136
t.Fatalf("decode: %v", err)
137
}
138
if body["status"] != "ok" {
139
t.Errorf("status: got %q, want ok", body["status"])
140
}
141
}
142
143
func TestRegisterAndGet(t *testing.T) {
144
srv := newTestServer(t)
145
defer srv.Close()
146
147
// Register
148
resp := do(t, srv, "POST", "/v1/agents/register", map[string]any{
149
"nick": "claude-01",
150
"type": "worker",
151
"channels": []string{"#fleet"},
152
}, authHeader())
153
defer resp.Body.Close()
154
if resp.StatusCode != http.StatusCreated {
155
t.Errorf("register: expected 201, got %d", resp.StatusCode)
156
}
157
158
var regBody map[string]any
159
if err := json.NewDecoder(resp.Body).Decode(&regBody); err != nil {
160
t.Fatalf("decode: %v", err)
161
}
162
if regBody["credentials"] == nil {
163
t.Error("credentials missing from response")
164
}
165
if regBody["payload"] == nil {
166
t.Error("payload missing from response")
167
}
168
169
// Get
170
resp2 := do(t, srv, "GET", "/v1/agents/claude-01", nil, authHeader())
171
defer resp2.Body.Close()
172
if resp2.StatusCode != http.StatusOK {
173
t.Errorf("get: expected 200, got %d", resp2.StatusCode)
174
}
175
}
176
177
func TestRegisterDuplicate(t *testing.T) {
178
srv := newTestServer(t)
179
defer srv.Close()
180
181
body := map[string]any{"nick": "agent-dup", "type": "worker"}
182
do(t, srv, "POST", "/v1/agents/register", body, authHeader()).Body.Close()
183
184
resp := do(t, srv, "POST", "/v1/agents/register", body, authHeader())
185
defer resp.Body.Close()
186
if resp.StatusCode != http.StatusConflict {
187
t.Errorf("expected 409 on duplicate, got %d", resp.StatusCode)
188
}
189
}
190
191
func TestListAgents(t *testing.T) {
192
srv := newTestServer(t)
193
defer srv.Close()
194
195
for _, nick := range []string{"a1", "a2", "a3"} {
196
do(t, srv, "POST", "/v1/agents/register", map[string]any{"nick": nick}, authHeader()).Body.Close()
197
}
198
199
resp := do(t, srv, "GET", "/v1/agents", nil, authHeader())
200
defer resp.Body.Close()
201
if resp.StatusCode != http.StatusOK {
202
t.Errorf("expected 200, got %d", resp.StatusCode)
203
}
204
205
var body map[string]any
206
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
207
t.Fatalf("decode: %v", err)
208
}
209
agents := body["agents"].([]any)
210
if len(agents) != 3 {
211
t.Errorf("expected 3 agents, got %d", len(agents))
212
}
213
}
214
215
func TestRotate(t *testing.T) {
216
srv := newTestServer(t)
217
defer srv.Close()
218
219
do(t, srv, "POST", "/v1/agents/register", map[string]any{"nick": "rot-agent"}, authHeader()).Body.Close()
220
221
resp := do(t, srv, "POST", "/v1/agents/rot-agent/rotate", nil, authHeader())
222
defer resp.Body.Close()
223
if resp.StatusCode != http.StatusOK {
224
t.Errorf("rotate: expected 200, got %d", resp.StatusCode)
225
}
226
}
227
228
func TestRevoke(t *testing.T) {
229
srv := newTestServer(t)
230
defer srv.Close()
231
232
do(t, srv, "POST", "/v1/agents/register", map[string]any{"nick": "rev-agent"}, authHeader()).Body.Close()
233
234
resp := do(t, srv, "POST", "/v1/agents/rev-agent/revoke", nil, authHeader())
235
defer resp.Body.Close()
236
if resp.StatusCode != http.StatusNoContent {
237
t.Errorf("revoke: expected 204, got %d", resp.StatusCode)
238
}
239
240
// Now get should 404.
241
resp2 := do(t, srv, "GET", "/v1/agents/rev-agent", nil, authHeader())
242
defer resp2.Body.Close()
243
if resp2.StatusCode != http.StatusNotFound {
244
t.Errorf("get revoked: expected 404, got %d", resp2.StatusCode)
245
}
246
}
247
248
func TestGetUnknown(t *testing.T) {
249
srv := newTestServer(t)
250
defer srv.Close()
251
252
resp := do(t, srv, "GET", "/v1/agents/nobody", nil, authHeader())
253
defer resp.Body.Close()
254
if resp.StatusCode != http.StatusNotFound {
255
t.Errorf("expected 404, got %d", resp.StatusCode)
256
}
257
}
258
259
func TestRegisterMissingNick(t *testing.T) {
260
srv := newTestServer(t)
261
defer srv.Close()
262
263
resp := do(t, srv, "POST", "/v1/agents/register", map[string]any{"type": "worker"}, authHeader())
264
defer resp.Body.Close()
265
if resp.StatusCode != http.StatusBadRequest {
266
t.Errorf("expected 400, got %d", resp.StatusCode)
267
}
268
}
269

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button