ScuttleBot

scuttlebot / internal / api / config_handlers_test.go
Blame History Raw 474 lines
1
package api
2
3
import (
4
"bytes"
5
"encoding/json"
6
"io"
7
"log/slog"
8
"net/http"
9
"net/http/httptest"
10
"path/filepath"
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
)
18
19
func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
20
t.Helper()
21
dir := t.TempDir()
22
path := filepath.Join(dir, "scuttlebot.yaml")
23
24
var cfg config.Config
25
cfg.Defaults()
26
cfg.Ergo.DataDir = dir
27
28
store := NewConfigStore(path, cfg)
29
reg := registry.New(nil, []byte("key"))
30
log := slog.New(slog.NewTextHandler(io.Discard, nil))
31
srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler())
32
t.Cleanup(srv.Close)
33
return srv, store
34
}
35
36
func TestHandleGetConfig(t *testing.T) {
37
srv, _ := newCfgTestServer(t)
38
39
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/config", nil)
40
req.Header.Set("Authorization", "Bearer tok")
41
resp, err := http.DefaultClient.Do(req)
42
if err != nil {
43
t.Fatal(err)
44
}
45
defer resp.Body.Close()
46
47
if resp.StatusCode != http.StatusOK {
48
t.Fatalf("want 200, got %d", resp.StatusCode)
49
}
50
var body map[string]any
51
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
52
t.Fatal(err)
53
}
54
if _, ok := body["bridge"]; !ok {
55
t.Error("response missing bridge section")
56
}
57
if _, ok := body["topology"]; !ok {
58
t.Error("response missing topology section")
59
}
60
}
61
62
func TestHandlePutConfig(t *testing.T) {
63
srv, store := newCfgTestServer(t)
64
65
update := map[string]any{
66
"bridge": map[string]any{
67
"web_user_ttl_minutes": 10,
68
},
69
"topology": map[string]any{
70
"nick": "topo-bot",
71
"channels": []map[string]any{
72
{"name": "#general", "topic": "Fleet"},
73
},
74
},
75
}
76
body, _ := json.Marshal(update)
77
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/v1/config", bytes.NewReader(body))
78
req.Header.Set("Authorization", "Bearer tok")
79
req.Header.Set("Content-Type", "application/json")
80
resp, err := http.DefaultClient.Do(req)
81
if err != nil {
82
t.Fatal(err)
83
}
84
defer resp.Body.Close()
85
86
if resp.StatusCode != http.StatusOK {
87
t.Fatalf("want 200, got %d", resp.StatusCode)
88
}
89
var result struct {
90
Saved bool `json:"saved"`
91
}
92
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
93
t.Fatal(err)
94
}
95
if !result.Saved {
96
t.Error("expected saved=true")
97
}
98
99
// Verify in-memory state updated.
100
got := store.Get()
101
if got.Bridge.WebUserTTLMinutes != 10 {
102
t.Errorf("bridge.web_user_ttl_minutes = %d, want 10", got.Bridge.WebUserTTLMinutes)
103
}
104
if got.Topology.Nick != "topo-bot" {
105
t.Errorf("topology.nick = %q, want topo-bot", got.Topology.Nick)
106
}
107
if len(got.Topology.Channels) != 1 || got.Topology.Channels[0].Name != "#general" {
108
t.Errorf("topology.channels = %+v", got.Topology.Channels)
109
}
110
}
111
112
func TestHandlePutConfigAgentPolicy(t *testing.T) {
113
srv, store := newCfgTestServer(t)
114
115
update := map[string]any{
116
"agent_policy": map[string]any{
117
"require_checkin": true,
118
"checkin_channel": "#fleet",
119
"required_channels": []string{"#general"},
120
},
121
}
122
body, _ := json.Marshal(update)
123
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/v1/config", bytes.NewReader(body))
124
req.Header.Set("Authorization", "Bearer tok")
125
req.Header.Set("Content-Type", "application/json")
126
resp, err := http.DefaultClient.Do(req)
127
if err != nil {
128
t.Fatal(err)
129
}
130
defer resp.Body.Close()
131
if resp.StatusCode != http.StatusOK {
132
t.Fatalf("want 200, got %d", resp.StatusCode)
133
}
134
135
got := store.Get()
136
if !got.AgentPolicy.RequireCheckin {
137
t.Error("agent_policy.require_checkin should be true")
138
}
139
if got.AgentPolicy.CheckinChannel != "#fleet" {
140
t.Errorf("agent_policy.checkin_channel = %q, want #fleet", got.AgentPolicy.CheckinChannel)
141
}
142
if len(got.AgentPolicy.RequiredChannels) != 1 || got.AgentPolicy.RequiredChannels[0] != "#general" {
143
t.Errorf("agent_policy.required_channels = %v", got.AgentPolicy.RequiredChannels)
144
}
145
}
146
147
func TestHandlePutConfigLogging(t *testing.T) {
148
srv, store := newCfgTestServer(t)
149
150
update := map[string]any{
151
"logging": map[string]any{
152
"enabled": true,
153
"dir": "./data/logs",
154
"format": "jsonl",
155
"rotation": "daily",
156
"per_channel": true,
157
"max_age_days": 30,
158
},
159
}
160
body, _ := json.Marshal(update)
161
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/v1/config", bytes.NewReader(body))
162
req.Header.Set("Authorization", "Bearer tok")
163
req.Header.Set("Content-Type", "application/json")
164
resp, err := http.DefaultClient.Do(req)
165
if err != nil {
166
t.Fatal(err)
167
}
168
defer resp.Body.Close()
169
if resp.StatusCode != http.StatusOK {
170
t.Fatalf("want 200, got %d", resp.StatusCode)
171
}
172
173
got := store.Get()
174
if !got.Logging.Enabled {
175
t.Error("logging.enabled should be true")
176
}
177
if got.Logging.Dir != "./data/logs" {
178
t.Errorf("logging.dir = %q, want ./data/logs", got.Logging.Dir)
179
}
180
if got.Logging.Format != "jsonl" {
181
t.Errorf("logging.format = %q, want jsonl", got.Logging.Format)
182
}
183
if got.Logging.Rotation != "daily" {
184
t.Errorf("logging.rotation = %q, want daily", got.Logging.Rotation)
185
}
186
if !got.Logging.PerChannel {
187
t.Error("logging.per_channel should be true")
188
}
189
if got.Logging.MaxAgeDays != 30 {
190
t.Errorf("logging.max_age_days = %d, want 30", got.Logging.MaxAgeDays)
191
}
192
}
193
194
func TestHandlePutConfigErgo(t *testing.T) {
195
srv, store := newCfgTestServer(t)
196
197
update := map[string]any{
198
"ergo": map[string]any{
199
"network_name": "testnet",
200
"server_name": "irc.test.local",
201
},
202
}
203
body, _ := json.Marshal(update)
204
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/v1/config", bytes.NewReader(body))
205
req.Header.Set("Authorization", "Bearer tok")
206
req.Header.Set("Content-Type", "application/json")
207
resp, err := http.DefaultClient.Do(req)
208
if err != nil {
209
t.Fatal(err)
210
}
211
defer resp.Body.Close()
212
if resp.StatusCode != http.StatusOK {
213
t.Fatalf("want 200, got %d", resp.StatusCode)
214
}
215
216
// Ergo changes should be flagged as restart_required.
217
var result struct {
218
Saved bool `json:"saved"`
219
RestartRequired []string `json:"restart_required"`
220
}
221
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
222
t.Fatal(err)
223
}
224
if !result.Saved {
225
t.Error("expected saved=true")
226
}
227
if len(result.RestartRequired) == 0 {
228
t.Error("expected restart_required to be non-empty for ergo changes")
229
}
230
231
got := store.Get()
232
if got.Ergo.NetworkName != "testnet" {
233
t.Errorf("ergo.network_name = %q, want testnet", got.Ergo.NetworkName)
234
}
235
if got.Ergo.ServerName != "irc.test.local" {
236
t.Errorf("ergo.server_name = %q, want irc.test.local", got.Ergo.ServerName)
237
}
238
}
239
240
func TestHandlePutConfigTLS(t *testing.T) {
241
srv, store := newCfgTestServer(t)
242
243
update := map[string]any{
244
"tls": map[string]any{
245
"domain": "example.com",
246
"email": "[email protected]",
247
"allow_insecure": true,
248
},
249
}
250
body, _ := json.Marshal(update)
251
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/v1/config", bytes.NewReader(body))
252
req.Header.Set("Authorization", "Bearer tok")
253
req.Header.Set("Content-Type", "application/json")
254
resp, err := http.DefaultClient.Do(req)
255
if err != nil {
256
t.Fatal(err)
257
}
258
defer resp.Body.Close()
259
if resp.StatusCode != http.StatusOK {
260
t.Fatalf("want 200, got %d", resp.StatusCode)
261
}
262
263
var result struct {
264
RestartRequired []string `json:"restart_required"`
265
}
266
json.NewDecoder(resp.Body).Decode(&result)
267
if len(result.RestartRequired) == 0 {
268
t.Error("expected restart_required for tls.domain change")
269
}
270
271
got := store.Get()
272
if got.TLS.Domain != "example.com" {
273
t.Errorf("tls.domain = %q, want example.com", got.TLS.Domain)
274
}
275
if got.TLS.Email != "[email protected]" {
276
t.Errorf("tls.email = %q, want [email protected]", got.TLS.Email)
277
}
278
if !got.TLS.AllowInsecure {
279
t.Error("tls.allow_insecure should be true")
280
}
281
}
282
283
func TestHandleGetConfigIncludesAgentPolicyAndLogging(t *testing.T) {
284
srv, store := newCfgTestServer(t)
285
286
cfg := store.Get()
287
cfg.AgentPolicy.RequireCheckin = true
288
cfg.AgentPolicy.CheckinChannel = "#ops"
289
cfg.Logging.Enabled = true
290
cfg.Logging.Format = "csv"
291
if err := store.Save(cfg); err != nil {
292
t.Fatalf("store.Save: %v", err)
293
}
294
295
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/config", nil)
296
req.Header.Set("Authorization", "Bearer tok")
297
resp, err := http.DefaultClient.Do(req)
298
if err != nil {
299
t.Fatal(err)
300
}
301
defer resp.Body.Close()
302
if resp.StatusCode != http.StatusOK {
303
t.Fatalf("want 200, got %d", resp.StatusCode)
304
}
305
306
var body map[string]any
307
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
308
t.Fatal(err)
309
}
310
ap, ok := body["agent_policy"].(map[string]any)
311
if !ok {
312
t.Fatal("response missing agent_policy section")
313
}
314
if ap["require_checkin"] != true {
315
t.Error("agent_policy.require_checkin should be true")
316
}
317
if ap["checkin_channel"] != "#ops" {
318
t.Errorf("agent_policy.checkin_channel = %v, want #ops", ap["checkin_channel"])
319
}
320
lg, ok := body["logging"].(map[string]any)
321
if !ok {
322
t.Fatal("response missing logging section")
323
}
324
if lg["enabled"] != true {
325
t.Error("logging.enabled should be true")
326
}
327
if lg["format"] != "csv" {
328
t.Errorf("logging.format = %v, want csv", lg["format"])
329
}
330
}
331
332
func TestHandleGetConfigHistoryEntry(t *testing.T) {
333
srv, store := newCfgTestServer(t)
334
335
// Save twice so a snapshot exists.
336
cfg := store.Get()
337
cfg.Bridge.WebUserTTLMinutes = 11
338
if err := store.Save(cfg); err != nil {
339
t.Fatalf("first save: %v", err)
340
}
341
cfg2 := store.Get()
342
cfg2.Bridge.WebUserTTLMinutes = 22
343
if err := store.Save(cfg2); err != nil {
344
t.Fatalf("second save: %v", err)
345
}
346
347
// List history to find a real filename.
348
entries, err := store.ListHistory()
349
if err != nil {
350
t.Fatalf("ListHistory: %v", err)
351
}
352
if len(entries) == 0 {
353
t.Skip("no history entries; snapshot may not have been created")
354
}
355
filename := entries[0].Filename
356
357
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/config/history/"+filename, nil)
358
req.Header.Set("Authorization", "Bearer tok")
359
resp, err := http.DefaultClient.Do(req)
360
if err != nil {
361
t.Fatal(err)
362
}
363
defer resp.Body.Close()
364
365
if resp.StatusCode != http.StatusOK {
366
t.Fatalf("want 200, got %d", resp.StatusCode)
367
}
368
var body map[string]any
369
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
370
t.Fatal(err)
371
}
372
if _, ok := body["bridge"]; !ok {
373
t.Error("history entry response missing bridge section")
374
}
375
}
376
377
func TestHandleGetConfigHistoryEntryNotFound(t *testing.T) {
378
srv, _ := newCfgTestServer(t)
379
380
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/config/history/nonexistent.yaml", nil)
381
req.Header.Set("Authorization", "Bearer tok")
382
resp, err := http.DefaultClient.Do(req)
383
if err != nil {
384
t.Fatal(err)
385
}
386
defer resp.Body.Close()
387
388
if resp.StatusCode != http.StatusNotFound {
389
t.Fatalf("want 404, got %d", resp.StatusCode)
390
}
391
}
392
393
func TestConfigStoreOnChange(t *testing.T) {
394
dir := t.TempDir()
395
path := filepath.Join(dir, "scuttlebot.yaml")
396
397
var cfg config.Config
398
cfg.Defaults()
399
cfg.Ergo.DataDir = dir
400
store := NewConfigStore(path, cfg)
401
402
done := make(chan config.Config, 1)
403
store.OnChange(func(c config.Config) { done <- c })
404
405
next := store.Get()
406
next.Bridge.WebUserTTLMinutes = 99
407
if err := store.Save(next); err != nil {
408
t.Fatalf("Save: %v", err)
409
}
410
411
select {
412
case c := <-done:
413
if c.Bridge.WebUserTTLMinutes != 99 {
414
t.Errorf("OnChange got TTL=%d, want 99", c.Bridge.WebUserTTLMinutes)
415
}
416
case <-time.After(2 * time.Second):
417
t.Error("OnChange callback not called within timeout")
418
}
419
}
420
421
func TestHandleGetConfigHistory(t *testing.T) {
422
srv, store := newCfgTestServer(t)
423
424
// Trigger a save to create a snapshot.
425
cfg := store.Get()
426
cfg.Bridge.WebUserTTLMinutes = 7
427
if err := store.Save(cfg); err != nil {
428
t.Fatalf("store.Save: %v", err)
429
}
430
431
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/config/history", nil)
432
req.Header.Set("Authorization", "Bearer tok")
433
resp, err := http.DefaultClient.Do(req)
434
if err != nil {
435
t.Fatal(err)
436
}
437
defer resp.Body.Close()
438
439
if resp.StatusCode != http.StatusOK {
440
t.Fatalf("want 200, got %d", resp.StatusCode)
441
}
442
var result struct {
443
Entries []config.HistoryEntry `json:"entries"`
444
}
445
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
446
t.Fatal(err)
447
}
448
// Save creates a snapshot of the *current* file before writing, but the
449
// config file didn't exist yet, so no snapshot is created. Second save creates one.
450
cfg2 := store.Get()
451
cfg2.Bridge.WebUserTTLMinutes = 9
452
if err := store.Save(cfg2); err != nil {
453
t.Fatalf("store.Save 2: %v", err)
454
}
455
456
req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/config/history", nil)
457
req2.Header.Set("Authorization", "Bearer tok")
458
resp2, err := http.DefaultClient.Do(req2)
459
if err != nil {
460
t.Fatal(err)
461
}
462
defer resp2.Body.Close()
463
464
var result2 struct {
465
Entries []config.HistoryEntry `json:"entries"`
466
}
467
if err := json.NewDecoder(resp2.Body).Decode(&result2); err != nil {
468
t.Fatal(err)
469
}
470
if len(result2.Entries) < 1 {
471
t.Errorf("want ≥1 history entries, got %d", len(result2.Entries))
472
}
473
}
474

Keyboard Shortcuts

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