ScuttleBot

scuttlebot / internal / api / channels_topology_test.go
Blame History Raw 396 lines
1
package api
2
3
import (
4
"bytes"
5
"encoding/json"
6
"fmt"
7
"io"
8
"log/slog"
9
"net/http"
10
"net/http/httptest"
11
"testing"
12
"time"
13
14
"github.com/conflicthq/scuttlebot/internal/auth"
15
"github.com/conflicthq/scuttlebot/internal/config"
16
"github.com/conflicthq/scuttlebot/internal/registry"
17
"github.com/conflicthq/scuttlebot/internal/topology"
18
)
19
20
// accessCall records a single GrantAccess or RevokeAccess invocation.
21
type accessCall struct {
22
Nick string
23
Channel string
24
Level string // "OP", "VOICE", or "" for revoke
25
}
26
27
// stubTopologyManager implements topologyManager for tests.
28
// It records the last ProvisionChannel call and returns a canned Policy.
29
type stubTopologyManager struct {
30
last topology.ChannelConfig
31
policy *topology.Policy
32
provErr error
33
grants []accessCall
34
revokes []accessCall
35
}
36
37
func (s *stubTopologyManager) ProvisionChannel(ch topology.ChannelConfig) error {
38
s.last = ch
39
return s.provErr
40
}
41
42
func (s *stubTopologyManager) DropChannel(_ string) {}
43
44
func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
45
46
func (s *stubTopologyManager) GrantAccess(nick, channel, level string) {
47
s.grants = append(s.grants, accessCall{Nick: nick, Channel: channel, Level: level})
48
}
49
50
func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
51
s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
52
}
53
54
func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
55
56
// stubProvisioner is a minimal AccountProvisioner for agent registration tests.
57
type stubProvisioner struct {
58
accounts map[string]string
59
}
60
61
func newStubProvisioner() *stubProvisioner {
62
return &stubProvisioner{accounts: make(map[string]string)}
63
}
64
65
func (p *stubProvisioner) RegisterAccount(name, pass string) error {
66
if _, ok := p.accounts[name]; ok {
67
return fmt.Errorf("ACCOUNT_EXISTS")
68
}
69
p.accounts[name] = pass
70
return nil
71
}
72
73
func (p *stubProvisioner) ChangePassword(name, pass string) error {
74
p.accounts[name] = pass
75
return nil
76
}
77
78
func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
79
t.Helper()
80
reg := registry.New(nil, []byte("key"))
81
log := slog.New(slog.NewTextHandler(io.Discard, nil))
82
srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
83
t.Cleanup(srv.Close)
84
return srv, "tok"
85
}
86
87
// newTopoTestServerWithRegistry creates a test server with both topology and a
88
// real registry backed by stubProvisioner, so agent registration works.
89
func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
90
t.Helper()
91
reg := registry.New(newStubProvisioner(), []byte("key"))
92
log := slog.New(slog.NewTextHandler(io.Discard, nil))
93
srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
94
t.Cleanup(srv.Close)
95
return srv, "tok"
96
}
97
98
func TestHandleProvisionChannel(t *testing.T) {
99
pol := topology.NewPolicy(config.TopologyConfig{
100
Types: []config.ChannelTypeConfig{
101
{
102
Name: "task",
103
Prefix: "task.",
104
Autojoin: []string{"bridge", "scribe"},
105
TTL: config.Duration{Duration: 72 * time.Hour},
106
},
107
},
108
})
109
stub := &stubTopologyManager{policy: pol}
110
srv, tok := newTopoTestServer(t, stub)
111
112
body, _ := json.Marshal(map[string]string{"name": "#task.gh-1"})
113
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels", bytes.NewReader(body))
114
req.Header.Set("Authorization", "Bearer "+tok)
115
req.Header.Set("Content-Type", "application/json")
116
resp, err := http.DefaultClient.Do(req)
117
if err != nil {
118
t.Fatal(err)
119
}
120
defer resp.Body.Close()
121
122
if resp.StatusCode != http.StatusCreated {
123
t.Fatalf("want 201, got %d", resp.StatusCode)
124
}
125
var got struct {
126
Channel string `json:"channel"`
127
Type string `json:"type"`
128
Autojoin []string `json:"autojoin"`
129
}
130
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
131
t.Fatal(err)
132
}
133
if got.Channel != "#task.gh-1" {
134
t.Errorf("channel = %q, want #task.gh-1", got.Channel)
135
}
136
if got.Type != "task" {
137
t.Errorf("type = %q, want task", got.Type)
138
}
139
if len(got.Autojoin) != 2 || got.Autojoin[0] != "bridge" {
140
t.Errorf("autojoin = %v, want [bridge scribe]", got.Autojoin)
141
}
142
// Verify autojoin was forwarded to ProvisionChannel.
143
if len(stub.last.Autojoin) != 2 {
144
t.Errorf("stub.last.Autojoin = %v, want [bridge scribe]", stub.last.Autojoin)
145
}
146
}
147
148
func TestHandleProvisionChannelInvalidName(t *testing.T) {
149
stub := &stubTopologyManager{}
150
srv, tok := newTopoTestServer(t, stub)
151
152
body, _ := json.Marshal(map[string]string{"name": "no-hash"})
153
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels", bytes.NewReader(body))
154
req.Header.Set("Authorization", "Bearer "+tok)
155
req.Header.Set("Content-Type", "application/json")
156
resp, err := http.DefaultClient.Do(req)
157
if err != nil {
158
t.Fatalf("do: %v", err)
159
}
160
defer resp.Body.Close()
161
if resp.StatusCode != http.StatusBadRequest {
162
t.Errorf("want 400, got %d", resp.StatusCode)
163
}
164
}
165
166
func TestHandleGetTopology(t *testing.T) {
167
pol := topology.NewPolicy(config.TopologyConfig{
168
Channels: []config.StaticChannelConfig{{Name: "#general"}},
169
Types: []config.ChannelTypeConfig{
170
{Name: "task", Prefix: "task.", Autojoin: []string{"bridge"}},
171
},
172
})
173
stub := &stubTopologyManager{policy: pol}
174
srv, tok := newTopoTestServer(t, stub)
175
176
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/topology", nil)
177
req.Header.Set("Authorization", "Bearer "+tok)
178
resp, err := http.DefaultClient.Do(req)
179
if err != nil {
180
t.Fatal(err)
181
}
182
defer resp.Body.Close()
183
184
if resp.StatusCode != http.StatusOK {
185
t.Fatalf("want 200, got %d", resp.StatusCode)
186
}
187
var got struct {
188
StaticChannels []string `json:"static_channels"`
189
Types []struct {
190
Name string `json:"name"`
191
Prefix string `json:"prefix"`
192
} `json:"types"`
193
}
194
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
195
t.Fatal(err)
196
}
197
if len(got.StaticChannels) != 1 || got.StaticChannels[0] != "#general" {
198
t.Errorf("static_channels = %v, want [#general]", got.StaticChannels)
199
}
200
if len(got.Types) != 1 || got.Types[0].Name != "task" {
201
t.Errorf("types = %v", got.Types)
202
}
203
}
204
205
// --- Agent mode assignment tests ---
206
207
// topoDoJSON is a helper for issuing authenticated JSON requests against a test server.
208
func topoDoJSON(t *testing.T, srv *httptest.Server, tok, method, path string, body any) *http.Response {
209
t.Helper()
210
var buf bytes.Buffer
211
if body != nil {
212
if err := json.NewEncoder(&buf).Encode(body); err != nil {
213
t.Fatalf("encode: %v", err)
214
}
215
}
216
req, _ := http.NewRequest(method, srv.URL+path, &buf)
217
req.Header.Set("Authorization", "Bearer "+tok)
218
req.Header.Set("Content-Type", "application/json")
219
resp, err := http.DefaultClient.Do(req)
220
if err != nil {
221
t.Fatalf("do: %v", err)
222
}
223
return resp
224
}
225
226
func TestRegisterGrantsOPForOrchestrator(t *testing.T) {
227
stub := &stubTopologyManager{}
228
srv, tok := newTopoTestServerWithRegistry(t, stub)
229
230
resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
231
"nick": "orch-1",
232
"type": "orchestrator",
233
"channels": []string{"#fleet", "#project.foo"},
234
})
235
defer resp.Body.Close()
236
if resp.StatusCode != http.StatusCreated {
237
t.Fatalf("register: want 201, got %d", resp.StatusCode)
238
}
239
240
if len(stub.grants) != 2 {
241
t.Fatalf("grants: want 2, got %d", len(stub.grants))
242
}
243
for i, want := range []accessCall{
244
{Nick: "orch-1", Channel: "#fleet", Level: "OP"},
245
{Nick: "orch-1", Channel: "#project.foo", Level: "OP"},
246
} {
247
if stub.grants[i] != want {
248
t.Errorf("grant[%d] = %+v, want %+v", i, stub.grants[i], want)
249
}
250
}
251
}
252
253
func TestRegisterGrantsVOICEForWorker(t *testing.T) {
254
stub := &stubTopologyManager{}
255
srv, tok := newTopoTestServerWithRegistry(t, stub)
256
257
resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
258
"nick": "worker-1",
259
"type": "worker",
260
"channels": []string{"#fleet"},
261
})
262
defer resp.Body.Close()
263
if resp.StatusCode != http.StatusCreated {
264
t.Fatalf("register: want 201, got %d", resp.StatusCode)
265
}
266
267
if len(stub.grants) != 1 {
268
t.Fatalf("grants: want 1, got %d", len(stub.grants))
269
}
270
if stub.grants[0].Level != "VOICE" {
271
t.Errorf("level = %q, want VOICE", stub.grants[0].Level)
272
}
273
}
274
275
func TestRegisterNoModeForObserver(t *testing.T) {
276
stub := &stubTopologyManager{}
277
srv, tok := newTopoTestServerWithRegistry(t, stub)
278
279
resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
280
"nick": "obs-1",
281
"type": "observer",
282
"channels": []string{"#fleet"},
283
})
284
defer resp.Body.Close()
285
if resp.StatusCode != http.StatusCreated {
286
t.Fatalf("register: want 201, got %d", resp.StatusCode)
287
}
288
289
if len(stub.grants) != 0 {
290
t.Errorf("grants: want 0, got %d — observer should get no mode", len(stub.grants))
291
}
292
}
293
294
func TestRegisterGrantsOPForOperator(t *testing.T) {
295
stub := &stubTopologyManager{}
296
srv, tok := newTopoTestServerWithRegistry(t, stub)
297
298
resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
299
"nick": "human-op",
300
"type": "operator",
301
"channels": []string{"#fleet"},
302
})
303
defer resp.Body.Close()
304
if resp.StatusCode != http.StatusCreated {
305
t.Fatalf("register: want 201, got %d", resp.StatusCode)
306
}
307
308
if len(stub.grants) != 1 {
309
t.Fatalf("grants: want 1, got %d", len(stub.grants))
310
}
311
if stub.grants[0].Level != "OP" {
312
t.Errorf("level = %q, want OP", stub.grants[0].Level)
313
}
314
}
315
316
func TestRegisterOrchestratorWithOpsChannels(t *testing.T) {
317
stub := &stubTopologyManager{}
318
srv, tok := newTopoTestServerWithRegistry(t, stub)
319
320
resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
321
"nick": "orch-ops",
322
"type": "orchestrator",
323
"channels": []string{"#fleet", "#project.foo", "#project.bar"},
324
"ops_channels": []string{"#fleet"},
325
})
326
defer resp.Body.Close()
327
if resp.StatusCode != http.StatusCreated {
328
t.Fatalf("register: want 201, got %d", resp.StatusCode)
329
}
330
331
if len(stub.grants) != 3 {
332
t.Fatalf("grants: want 3, got %d", len(stub.grants))
333
}
334
for i, want := range []accessCall{
335
{Nick: "orch-ops", Channel: "#fleet", Level: "OP"},
336
{Nick: "orch-ops", Channel: "#project.foo", Level: "VOICE"},
337
{Nick: "orch-ops", Channel: "#project.bar", Level: "VOICE"},
338
} {
339
if stub.grants[i] != want {
340
t.Errorf("grant[%d] = %+v, want %+v", i, stub.grants[i], want)
341
}
342
}
343
}
344
345
func TestRevokeRemovesAccess(t *testing.T) {
346
stub := &stubTopologyManager{}
347
srv, tok := newTopoTestServerWithRegistry(t, stub)
348
349
resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
350
"nick": "orch-rev",
351
"type": "orchestrator",
352
"channels": []string{"#fleet", "#project.x"},
353
})
354
resp.Body.Close()
355
356
resp = topoDoJSON(t, srv, tok, "POST", "/v1/agents/orch-rev/revoke", nil)
357
defer resp.Body.Close()
358
if resp.StatusCode != http.StatusNoContent {
359
t.Fatalf("revoke: want 204, got %d", resp.StatusCode)
360
}
361
362
if len(stub.revokes) != 2 {
363
t.Fatalf("revokes: want 2, got %d", len(stub.revokes))
364
}
365
for i, want := range []string{"#fleet", "#project.x"} {
366
if stub.revokes[i].Channel != want {
367
t.Errorf("revoke[%d].Channel = %q, want %q", i, stub.revokes[i].Channel, want)
368
}
369
}
370
}
371
372
func TestDeleteRemovesAccess(t *testing.T) {
373
stub := &stubTopologyManager{}
374
srv, tok := newTopoTestServerWithRegistry(t, stub)
375
376
resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
377
"nick": "del-agent",
378
"type": "worker",
379
"channels": []string{"#fleet"},
380
})
381
resp.Body.Close()
382
383
resp = topoDoJSON(t, srv, tok, "DELETE", "/v1/agents/del-agent", nil)
384
defer resp.Body.Close()
385
if resp.StatusCode != http.StatusNoContent {
386
t.Fatalf("delete: want 204, got %d", resp.StatusCode)
387
}
388
389
if len(stub.revokes) != 1 {
390
t.Fatalf("revokes: want 1, got %d", len(stub.revokes))
391
}
392
if stub.revokes[0].Nick != "del-agent" || stub.revokes[0].Channel != "#fleet" {
393
t.Errorf("revoke = %+v, want del-agent on #fleet", stub.revokes[0])
394
}
395
}
396

Keyboard Shortcuts

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