ScuttleBot

scuttlebot / internal / registry / registry_test.go
Blame History Raw 429 lines
1
package registry_test
2
3
import (
4
"fmt"
5
"sync"
6
"testing"
7
8
"github.com/conflicthq/scuttlebot/internal/registry"
9
)
10
11
// mockProvisioner records calls for test assertions.
12
type mockProvisioner struct {
13
mu sync.Mutex
14
accounts map[string]string // nick → passphrase
15
}
16
17
func newMockProvisioner() *mockProvisioner {
18
return &mockProvisioner{accounts: make(map[string]string)}
19
}
20
21
func (m *mockProvisioner) RegisterAccount(name, passphrase string) error {
22
m.mu.Lock()
23
defer m.mu.Unlock()
24
if _, exists := m.accounts[name]; exists {
25
return fmt.Errorf("ACCOUNT_EXISTS")
26
}
27
m.accounts[name] = passphrase
28
return nil
29
}
30
31
func (m *mockProvisioner) ChangePassword(name, passphrase string) error {
32
m.mu.Lock()
33
defer m.mu.Unlock()
34
if _, exists := m.accounts[name]; !exists {
35
return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
36
}
37
m.accounts[name] = passphrase
38
return nil
39
}
40
41
func (m *mockProvisioner) passphrase(nick string) string {
42
m.mu.Lock()
43
defer m.mu.Unlock()
44
return m.accounts[nick]
45
}
46
47
var testKey = []byte("test-signing-key-do-not-use-in-production")
48
49
func cfg(channels, permissions []string) registry.EngagementConfig {
50
return registry.EngagementConfig{Channels: channels, Permissions: permissions}
51
}
52
53
func TestRegister(t *testing.T) {
54
p := newMockProvisioner()
55
r := registry.New(p, testKey)
56
57
creds, payload, err := r.Register("claude-01", registry.AgentTypeWorker,
58
cfg([]string{"#fleet", "#project.test"}, []string{"task.create"}))
59
if err != nil {
60
t.Fatalf("Register: %v", err)
61
}
62
63
if creds.Nick != "claude-01" {
64
t.Errorf("Nick: got %q, want %q", creds.Nick, "claude-01")
65
}
66
if creds.Passphrase == "" {
67
t.Error("Passphrase is empty")
68
}
69
if p.passphrase("claude-01") == "" {
70
t.Error("account not created in provisioner")
71
}
72
if payload.Payload.Nick != "claude-01" {
73
t.Errorf("payload Nick: got %q", payload.Payload.Nick)
74
}
75
if payload.Signature == "" {
76
t.Error("payload signature is empty")
77
}
78
if len(payload.Payload.Config.Channels) != 2 {
79
t.Errorf("payload channels: got %d, want 2", len(payload.Payload.Config.Channels))
80
}
81
}
82
83
func TestRegisterDuplicate(t *testing.T) {
84
p := newMockProvisioner()
85
r := registry.New(p, testKey)
86
87
if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
88
t.Fatalf("first Register: %v", err)
89
}
90
if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err == nil {
91
t.Error("expected error on duplicate registration, got nil")
92
}
93
}
94
95
func TestRotate(t *testing.T) {
96
p := newMockProvisioner()
97
r := registry.New(p, testKey)
98
99
creds, _, err := r.Register("agent-02", registry.AgentTypeWorker, registry.EngagementConfig{})
100
if err != nil {
101
t.Fatalf("Register: %v", err)
102
}
103
original := creds.Passphrase
104
105
newCreds, err := r.Rotate("agent-02")
106
if err != nil {
107
t.Fatalf("Rotate: %v", err)
108
}
109
if newCreds.Passphrase == original {
110
t.Error("passphrase should change after rotation")
111
}
112
if p.passphrase("agent-02") != newCreds.Passphrase {
113
t.Error("provisioner passphrase should match rotated credentials")
114
}
115
}
116
117
func TestRevoke(t *testing.T) {
118
p := newMockProvisioner()
119
r := registry.New(p, testKey)
120
121
creds, _, err := r.Register("agent-03", registry.AgentTypeWorker, registry.EngagementConfig{})
122
if err != nil {
123
t.Fatalf("Register: %v", err)
124
}
125
126
if err := r.Revoke("agent-03"); err != nil {
127
t.Fatalf("Revoke: %v", err)
128
}
129
130
if p.passphrase("agent-03") == creds.Passphrase {
131
t.Error("passphrase should change after revocation")
132
}
133
if _, err := r.Get("agent-03"); err == nil {
134
t.Error("Get should fail for revoked agent")
135
}
136
}
137
138
func TestVerifyPayload(t *testing.T) {
139
p := newMockProvisioner()
140
r := registry.New(p, testKey)
141
142
_, payload, err := r.Register("agent-04", registry.AgentTypeOrchestrator,
143
cfg([]string{"#fleet"}, []string{"task.create", "task.assign"}))
144
if err != nil {
145
t.Fatalf("Register: %v", err)
146
}
147
148
if err := registry.VerifyPayload(payload, testKey); err != nil {
149
t.Errorf("VerifyPayload: %v", err)
150
}
151
152
// Tamper with the payload.
153
payload.Payload.Nick = "evil-agent"
154
if err := registry.VerifyPayload(payload, testKey); err == nil {
155
t.Error("VerifyPayload should fail after tampering")
156
}
157
}
158
159
func TestList(t *testing.T) {
160
p := newMockProvisioner()
161
r := registry.New(p, testKey)
162
163
for _, nick := range []string{"a", "b", "c"} {
164
if _, _, err := r.Register(nick, registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
165
t.Fatalf("Register %q: %v", nick, err)
166
}
167
}
168
if err := r.Revoke("b"); err != nil {
169
t.Fatalf("Revoke: %v", err)
170
}
171
172
agents := r.List()
173
// 3 registered (a, b, c), b revoked — List returns all including revoked.
174
registered := []string{"a", "b", "c"}
175
if len(agents) != len(registered) {
176
t.Errorf("List: got %d agents, want %d", len(agents), len(registered))
177
}
178
var revokedCount int
179
for _, a := range agents {
180
if a.Revoked {
181
revokedCount++
182
}
183
}
184
if revokedCount != 1 {
185
t.Errorf("List: got %d revoked, want 1", revokedCount)
186
}
187
}
188
189
func TestEngagementConfigValidation(t *testing.T) {
190
tests := []struct {
191
name string
192
cfg registry.EngagementConfig
193
wantErr bool
194
}{
195
{
196
name: "valid full config",
197
cfg: registry.EngagementConfig{
198
Channels: []string{"#fleet", "#project.test"},
199
OpsChannels: []string{"#fleet"},
200
Permissions: []string{"task.create"},
201
RateLimit: registry.RateLimitConfig{MessagesPerSecond: 10, Burst: 20},
202
Rules: registry.EngagementRules{
203
RespondToTypes: []string{"task.create"},
204
IgnoreNicks: []string{"scribe"},
205
},
206
},
207
wantErr: false,
208
},
209
{
210
name: "empty config is valid",
211
cfg: registry.EngagementConfig{},
212
wantErr: false,
213
},
214
{
215
name: "channel missing hash",
216
cfg: registry.EngagementConfig{Channels: []string{"fleet"}},
217
wantErr: true,
218
},
219
{
220
name: "channel with space",
221
cfg: registry.EngagementConfig{Channels: []string{"#fleet channel"}},
222
wantErr: true,
223
},
224
{
225
name: "ops_channel not in channels",
226
cfg: registry.EngagementConfig{Channels: []string{"#fleet"}, OpsChannels: []string{"#other"}},
227
wantErr: true,
228
},
229
{
230
name: "negative rate limit",
231
cfg: registry.EngagementConfig{RateLimit: registry.RateLimitConfig{MessagesPerSecond: -1}},
232
wantErr: true,
233
},
234
{
235
name: "negative burst",
236
cfg: registry.EngagementConfig{RateLimit: registry.RateLimitConfig{Burst: -5}},
237
wantErr: true,
238
},
239
{
240
name: "empty respond_to_type",
241
cfg: registry.EngagementConfig{Rules: registry.EngagementRules{RespondToTypes: []string{""}}},
242
wantErr: true,
243
},
244
}
245
246
for _, tt := range tests {
247
t.Run(tt.name, func(t *testing.T) {
248
err := tt.cfg.Validate()
249
if (err != nil) != tt.wantErr {
250
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
251
}
252
})
253
}
254
}
255
256
func TestRegisterInvalidConfig(t *testing.T) {
257
p := newMockProvisioner()
258
r := registry.New(p, testKey)
259
260
_, _, err := r.Register("bad-agent", registry.AgentTypeWorker, registry.EngagementConfig{
261
Channels: []string{"no-hash-here"},
262
})
263
if err == nil {
264
t.Error("expected error for invalid channel name, got nil")
265
}
266
// Account should not have been created.
267
if p.passphrase("bad-agent") != "" {
268
t.Error("account should not be created when config is invalid")
269
}
270
}
271
272
func TestAdopt(t *testing.T) {
273
p := newMockProvisioner()
274
r := registry.New(p, testKey)
275
276
payload, err := r.Adopt("preexisting-bot", registry.AgentTypeWorker,
277
cfg([]string{"#fleet"}, []string{"read"}))
278
if err != nil {
279
t.Fatalf("Adopt: %v", err)
280
}
281
if payload.Payload.Nick != "preexisting-bot" {
282
t.Errorf("payload Nick = %q, want preexisting-bot", payload.Payload.Nick)
283
}
284
// Adopt must NOT create a NickServ account (password should be empty in mock).
285
if p.passphrase("preexisting-bot") != "" {
286
t.Error("Adopt should not create a NickServ account")
287
}
288
// Agent should be visible in the registry.
289
agent, err := r.Get("preexisting-bot")
290
if err != nil {
291
t.Fatalf("Get after Adopt: %v", err)
292
}
293
if agent.Nick != "preexisting-bot" {
294
t.Errorf("Get Nick = %q", agent.Nick)
295
}
296
}
297
298
func TestAdoptDuplicate(t *testing.T) {
299
p := newMockProvisioner()
300
r := registry.New(p, testKey)
301
302
if _, err := r.Adopt("bot-dup", registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
303
t.Fatalf("first Adopt: %v", err)
304
}
305
if _, err := r.Adopt("bot-dup", registry.AgentTypeWorker, registry.EngagementConfig{}); err == nil {
306
t.Error("expected error on duplicate Adopt, got nil")
307
}
308
}
309
310
func TestDelete(t *testing.T) {
311
p := newMockProvisioner()
312
r := registry.New(p, testKey)
313
314
if _, _, err := r.Register("del-agent", registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
315
t.Fatalf("Register: %v", err)
316
}
317
318
if err := r.Delete("del-agent"); err != nil {
319
t.Fatalf("Delete: %v", err)
320
}
321
322
// Agent must no longer appear in List.
323
for _, a := range r.List() {
324
if a.Nick == "del-agent" {
325
t.Error("deleted agent should not appear in List()")
326
}
327
}
328
329
// Get must fail.
330
if _, err := r.Get("del-agent"); err == nil {
331
t.Error("Get should fail for deleted agent")
332
}
333
}
334
335
func TestDeleteRevoked(t *testing.T) {
336
// Deleting a revoked agent should succeed (lockout step skipped).
337
p := newMockProvisioner()
338
r := registry.New(p, testKey)
339
340
if _, _, err := r.Register("rev-del", registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
341
t.Fatalf("Register: %v", err)
342
}
343
if err := r.Revoke("rev-del"); err != nil {
344
t.Fatalf("Revoke: %v", err)
345
}
346
if err := r.Delete("rev-del"); err != nil {
347
t.Fatalf("Delete of revoked agent: %v", err)
348
}
349
}
350
351
func TestDeleteNotFound(t *testing.T) {
352
p := newMockProvisioner()
353
r := registry.New(p, testKey)
354
if err := r.Delete("nobody"); err == nil {
355
t.Error("expected error deleting non-existent agent, got nil")
356
}
357
}
358
359
func TestUpdateChannels(t *testing.T) {
360
p := newMockProvisioner()
361
r := registry.New(p, testKey)
362
363
if _, _, err := r.Register("chan-agent", registry.AgentTypeWorker,
364
cfg([]string{"#fleet"}, nil)); err != nil {
365
t.Fatalf("Register: %v", err)
366
}
367
368
newChans := []string{"#fleet", "#project.foo"}
369
if err := r.UpdateChannels("chan-agent", newChans); err != nil {
370
t.Fatalf("UpdateChannels: %v", err)
371
}
372
373
agent, err := r.Get("chan-agent")
374
if err != nil {
375
t.Fatalf("Get: %v", err)
376
}
377
if len(agent.Channels) != 2 {
378
t.Errorf("Channels len = %d, want 2", len(agent.Channels))
379
}
380
if agent.Channels[1] != "#project.foo" {
381
t.Errorf("Channels[1] = %q, want #project.foo", agent.Channels[1])
382
}
383
}
384
385
func TestUpdateChannelsNotFound(t *testing.T) {
386
p := newMockProvisioner()
387
r := registry.New(p, testKey)
388
if err := r.UpdateChannels("ghost", []string{"#fleet"}); err == nil {
389
t.Error("expected error for unknown agent, got nil")
390
}
391
}
392
393
func TestSetDataPathPersistence(t *testing.T) {
394
dataPath := t.TempDir() + "/agents.json"
395
p := newMockProvisioner()
396
r := registry.New(p, testKey)
397
398
if err := r.SetDataPath(dataPath); err != nil {
399
t.Fatalf("SetDataPath: %v", err)
400
}
401
402
if _, _, err := r.Register("persist-me", registry.AgentTypeWorker,
403
cfg([]string{"#fleet"}, nil)); err != nil {
404
t.Fatalf("Register: %v", err)
405
}
406
407
// New registry loaded from the same path — must contain the persisted agent.
408
r2 := registry.New(newMockProvisioner(), testKey)
409
if err := r2.SetDataPath(dataPath); err != nil {
410
t.Fatalf("SetDataPath (r2): %v", err)
411
}
412
413
agent, err := r2.Get("persist-me")
414
if err != nil {
415
t.Fatalf("Get after reload: %v", err)
416
}
417
if agent.Nick != "persist-me" {
418
t.Errorf("reloaded Nick = %q, want persist-me", agent.Nick)
419
}
420
}
421
422
func TestSetDataPathMissingFileOK(t *testing.T) {
423
r := registry.New(newMockProvisioner(), testKey)
424
// Path doesn't exist yet — should not error.
425
if err := r.SetDataPath(t.TempDir() + "/agents.json"); err != nil {
426
t.Errorf("SetDataPath on missing file: %v", err)
427
}
428
}
429

Keyboard Shortcuts

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