|
1
|
package config |
|
2
|
|
|
3
|
import ( |
|
4
|
"encoding/json" |
|
5
|
"os" |
|
6
|
"path/filepath" |
|
7
|
"testing" |
|
8
|
"time" |
|
9
|
) |
|
10
|
|
|
11
|
func TestTopologyConfigParse(t *testing.T) { |
|
12
|
yaml := ` |
|
13
|
topology: |
|
14
|
channels: |
|
15
|
- name: "#general" |
|
16
|
topic: "Fleet coordination" |
|
17
|
ops: [bridge, oracle] |
|
18
|
autojoin: [bridge, oracle, scribe] |
|
19
|
- name: "#alerts" |
|
20
|
autojoin: [bridge, sentinel] |
|
21
|
types: |
|
22
|
- name: task |
|
23
|
prefix: "task." |
|
24
|
autojoin: [bridge, scribe] |
|
25
|
supervision: "#general" |
|
26
|
ephemeral: true |
|
27
|
ttl: 72h |
|
28
|
- name: sprint |
|
29
|
prefix: "sprint." |
|
30
|
autojoin: [bridge, oracle, herald] |
|
31
|
- name: incident |
|
32
|
prefix: "incident." |
|
33
|
autojoin: [bridge, sentinel, steward] |
|
34
|
supervision: "#alerts" |
|
35
|
ephemeral: true |
|
36
|
ttl: 168h |
|
37
|
` |
|
38
|
f := filepath.Join(t.TempDir(), "scuttlebot.yaml") |
|
39
|
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { |
|
40
|
t.Fatal(err) |
|
41
|
} |
|
42
|
|
|
43
|
var cfg Config |
|
44
|
cfg.Defaults() |
|
45
|
if err := cfg.LoadFile(f); err != nil { |
|
46
|
t.Fatalf("LoadFile: %v", err) |
|
47
|
} |
|
48
|
|
|
49
|
top := cfg.Topology |
|
50
|
|
|
51
|
// static channels |
|
52
|
if len(top.Channels) != 2 { |
|
53
|
t.Fatalf("want 2 static channels, got %d", len(top.Channels)) |
|
54
|
} |
|
55
|
general := top.Channels[0] |
|
56
|
if general.Name != "#general" { |
|
57
|
t.Errorf("channel[0].Name = %q, want #general", general.Name) |
|
58
|
} |
|
59
|
if general.Topic != "Fleet coordination" { |
|
60
|
t.Errorf("channel[0].Topic = %q", general.Topic) |
|
61
|
} |
|
62
|
if len(general.Autojoin) != 3 { |
|
63
|
t.Errorf("channel[0].Autojoin len = %d, want 3", len(general.Autojoin)) |
|
64
|
} |
|
65
|
|
|
66
|
// types |
|
67
|
if len(top.Types) != 3 { |
|
68
|
t.Fatalf("want 3 types, got %d", len(top.Types)) |
|
69
|
} |
|
70
|
task := top.Types[0] |
|
71
|
if task.Name != "task" { |
|
72
|
t.Errorf("types[0].Name = %q, want task", task.Name) |
|
73
|
} |
|
74
|
if task.Prefix != "task." { |
|
75
|
t.Errorf("types[0].Prefix = %q, want task.", task.Prefix) |
|
76
|
} |
|
77
|
if task.Supervision != "#general" { |
|
78
|
t.Errorf("types[0].Supervision = %q, want #general", task.Supervision) |
|
79
|
} |
|
80
|
if !task.Ephemeral { |
|
81
|
t.Error("types[0].Ephemeral should be true") |
|
82
|
} |
|
83
|
if task.TTL.Duration != 72*time.Hour { |
|
84
|
t.Errorf("types[0].TTL = %v, want 72h", task.TTL.Duration) |
|
85
|
} |
|
86
|
|
|
87
|
incident := top.Types[2] |
|
88
|
if incident.TTL.Duration != 168*time.Hour { |
|
89
|
t.Errorf("types[2].TTL = %v, want 168h", incident.TTL.Duration) |
|
90
|
} |
|
91
|
} |
|
92
|
|
|
93
|
func TestTopologyConfigEmpty(t *testing.T) { |
|
94
|
yaml := `bridge: |
|
95
|
enabled: true |
|
96
|
` |
|
97
|
f := filepath.Join(t.TempDir(), "scuttlebot.yaml") |
|
98
|
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { |
|
99
|
t.Fatal(err) |
|
100
|
} |
|
101
|
|
|
102
|
var cfg Config |
|
103
|
cfg.Defaults() |
|
104
|
if err := cfg.LoadFile(f); err != nil { |
|
105
|
t.Fatalf("LoadFile: %v", err) |
|
106
|
} |
|
107
|
|
|
108
|
// No topology section — should be zero value, not an error. |
|
109
|
if len(cfg.Topology.Channels) != 0 { |
|
110
|
t.Errorf("expected no static channels, got %d", len(cfg.Topology.Channels)) |
|
111
|
} |
|
112
|
if len(cfg.Topology.Types) != 0 { |
|
113
|
t.Errorf("expected no types, got %d", len(cfg.Topology.Types)) |
|
114
|
} |
|
115
|
} |
|
116
|
|
|
117
|
func TestDurationUnmarshal(t *testing.T) { |
|
118
|
cases := []struct { |
|
119
|
input string |
|
120
|
want time.Duration |
|
121
|
}{ |
|
122
|
{"72h", 72 * time.Hour}, |
|
123
|
{"30m", 30 * time.Minute}, |
|
124
|
{"168h", 168 * time.Hour}, |
|
125
|
{"0s", 0}, |
|
126
|
} |
|
127
|
for _, tc := range cases { |
|
128
|
yaml := `topology: |
|
129
|
types: |
|
130
|
- name: x |
|
131
|
prefix: "x." |
|
132
|
ttl: ` + tc.input + "\n" |
|
133
|
f := filepath.Join(t.TempDir(), "cfg.yaml") |
|
134
|
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { |
|
135
|
t.Fatal(err) |
|
136
|
} |
|
137
|
var cfg Config |
|
138
|
if err := cfg.LoadFile(f); err != nil { |
|
139
|
t.Fatalf("input %q: %v", tc.input, err) |
|
140
|
} |
|
141
|
got := cfg.Topology.Types[0].TTL.Duration |
|
142
|
if got != tc.want { |
|
143
|
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.want) |
|
144
|
} |
|
145
|
} |
|
146
|
} |
|
147
|
|
|
148
|
func TestDurationJSONRoundTrip(t *testing.T) { |
|
149
|
cases := []struct { |
|
150
|
dur time.Duration |
|
151
|
want string |
|
152
|
}{ |
|
153
|
{72 * time.Hour, `"72h0m0s"`}, |
|
154
|
{30 * time.Minute, `"30m0s"`}, |
|
155
|
{0, `"0s"`}, |
|
156
|
} |
|
157
|
for _, tc := range cases { |
|
158
|
d := Duration{tc.dur} |
|
159
|
b, err := json.Marshal(d) |
|
160
|
if err != nil { |
|
161
|
t.Fatalf("Marshal(%v): %v", tc.dur, err) |
|
162
|
} |
|
163
|
if string(b) != tc.want { |
|
164
|
t.Errorf("Marshal(%v) = %s, want %s", tc.dur, b, tc.want) |
|
165
|
} |
|
166
|
var back Duration |
|
167
|
if err := json.Unmarshal(b, &back); err != nil { |
|
168
|
t.Fatalf("Unmarshal(%s): %v", b, err) |
|
169
|
} |
|
170
|
if back.Duration != tc.dur { |
|
171
|
t.Errorf("round-trip(%v): got %v", tc.dur, back.Duration) |
|
172
|
} |
|
173
|
} |
|
174
|
} |
|
175
|
|
|
176
|
func TestDurationJSONUnmarshalErrors(t *testing.T) { |
|
177
|
cases := []struct{ input string }{ |
|
178
|
{`123`}, // not a quoted string |
|
179
|
{`"notadur"`}, // not parseable |
|
180
|
{`""`}, // empty string |
|
181
|
} |
|
182
|
for _, tc := range cases { |
|
183
|
var d Duration |
|
184
|
if err := json.Unmarshal([]byte(tc.input), &d); err == nil { |
|
185
|
t.Errorf("Unmarshal(%s): expected error, got nil", tc.input) |
|
186
|
} |
|
187
|
} |
|
188
|
} |
|
189
|
|
|
190
|
func TestApplyEnv(t *testing.T) { |
|
191
|
cases := []struct { |
|
192
|
envKey string |
|
193
|
check func(c Config) bool |
|
194
|
}{ |
|
195
|
{"SCUTTLEBOT_API_ADDR", func(c Config) bool { return c.APIAddr == ":9999" }}, |
|
196
|
{"SCUTTLEBOT_MCP_ADDR", func(c Config) bool { return c.MCPAddr == ":9998" }}, |
|
197
|
{"SCUTTLEBOT_DB_DRIVER", func(c Config) bool { return c.Datastore.Driver == "postgres" }}, |
|
198
|
{"SCUTTLEBOT_DB_DSN", func(c Config) bool { return c.Datastore.DSN == "postgres://test" }}, |
|
199
|
{"SCUTTLEBOT_ERGO_EXTERNAL", func(c Config) bool { return c.Ergo.External }}, |
|
200
|
{"SCUTTLEBOT_ERGO_API_ADDR", func(c Config) bool { return c.Ergo.APIAddr == "http://ergo:8089" }}, |
|
201
|
{"SCUTTLEBOT_ERGO_API_TOKEN", func(c Config) bool { return c.Ergo.APIToken == "tok123" }}, |
|
202
|
{"SCUTTLEBOT_ERGO_IRC_ADDR", func(c Config) bool { return c.Ergo.IRCAddr == "ergo:6667" }}, |
|
203
|
{"SCUTTLEBOT_ERGO_NETWORK_NAME", func(c Config) bool { return c.Ergo.NetworkName == "testnet" }}, |
|
204
|
{"SCUTTLEBOT_ERGO_SERVER_NAME", func(c Config) bool { return c.Ergo.ServerName == "irc.test.local" }}, |
|
205
|
} |
|
206
|
|
|
207
|
envValues := map[string]string{ |
|
208
|
"SCUTTLEBOT_API_ADDR": ":9999", |
|
209
|
"SCUTTLEBOT_MCP_ADDR": ":9998", |
|
210
|
"SCUTTLEBOT_DB_DRIVER": "postgres", |
|
211
|
"SCUTTLEBOT_DB_DSN": "postgres://test", |
|
212
|
"SCUTTLEBOT_ERGO_EXTERNAL": "true", |
|
213
|
"SCUTTLEBOT_ERGO_API_ADDR": "http://ergo:8089", |
|
214
|
"SCUTTLEBOT_ERGO_API_TOKEN": "tok123", |
|
215
|
"SCUTTLEBOT_ERGO_IRC_ADDR": "ergo:6667", |
|
216
|
"SCUTTLEBOT_ERGO_NETWORK_NAME": "testnet", |
|
217
|
"SCUTTLEBOT_ERGO_SERVER_NAME": "irc.test.local", |
|
218
|
} |
|
219
|
|
|
220
|
for _, tc := range cases { |
|
221
|
t.Run(tc.envKey, func(t *testing.T) { |
|
222
|
t.Setenv(tc.envKey, envValues[tc.envKey]) |
|
223
|
var c Config |
|
224
|
c.Defaults() |
|
225
|
c.ApplyEnv() |
|
226
|
if !tc.check(c) { |
|
227
|
t.Errorf("%s=%q did not apply correctly", tc.envKey, envValues[tc.envKey]) |
|
228
|
} |
|
229
|
}) |
|
230
|
} |
|
231
|
} |
|
232
|
|
|
233
|
func TestApplyEnvErgoExternalFalseByDefault(t *testing.T) { |
|
234
|
// SCUTTLEBOT_ERGO_EXTERNAL absent — should not force External=true. |
|
235
|
var c Config |
|
236
|
c.Defaults() |
|
237
|
c.ApplyEnv() |
|
238
|
if c.Ergo.External { |
|
239
|
t.Error("Ergo.External should be false when env var is absent") |
|
240
|
} |
|
241
|
} |
|
242
|
|
|
243
|
func TestConfigSaveAndLoad(t *testing.T) { |
|
244
|
dir := t.TempDir() |
|
245
|
path := filepath.Join(dir, "scuttlebot.yaml") |
|
246
|
|
|
247
|
var orig Config |
|
248
|
orig.Defaults() |
|
249
|
orig.Bridge.WebUserTTLMinutes = 42 |
|
250
|
orig.AgentPolicy.RequireCheckin = true |
|
251
|
orig.AgentPolicy.CheckinChannel = "#fleet" |
|
252
|
orig.Logging.Enabled = true |
|
253
|
orig.Logging.Format = "jsonl" |
|
254
|
|
|
255
|
if err := orig.Save(path); err != nil { |
|
256
|
t.Fatalf("Save: %v", err) |
|
257
|
} |
|
258
|
|
|
259
|
var loaded Config |
|
260
|
loaded.Defaults() |
|
261
|
if err := loaded.LoadFile(path); err != nil { |
|
262
|
t.Fatalf("LoadFile: %v", err) |
|
263
|
} |
|
264
|
|
|
265
|
if loaded.Bridge.WebUserTTLMinutes != 42 { |
|
266
|
t.Errorf("WebUserTTLMinutes = %d, want 42", loaded.Bridge.WebUserTTLMinutes) |
|
267
|
} |
|
268
|
if !loaded.AgentPolicy.RequireCheckin { |
|
269
|
t.Error("AgentPolicy.RequireCheckin should be true") |
|
270
|
} |
|
271
|
if loaded.AgentPolicy.CheckinChannel != "#fleet" { |
|
272
|
t.Errorf("CheckinChannel = %q, want #fleet", loaded.AgentPolicy.CheckinChannel) |
|
273
|
} |
|
274
|
if !loaded.Logging.Enabled { |
|
275
|
t.Error("Logging.Enabled should be true") |
|
276
|
} |
|
277
|
if loaded.Logging.Format != "jsonl" { |
|
278
|
t.Errorf("Logging.Format = %q, want jsonl", loaded.Logging.Format) |
|
279
|
} |
|
280
|
} |
|
281
|
|
|
282
|
func TestLoadFileMissingIsNotError(t *testing.T) { |
|
283
|
var c Config |
|
284
|
c.Defaults() |
|
285
|
if err := c.LoadFile("/nonexistent/path/scuttlebot.yaml"); err != nil { |
|
286
|
t.Errorf("LoadFile on missing file should return nil, got %v", err) |
|
287
|
} |
|
288
|
} |
|
289
|
|