ScuttleBot

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

Keyboard Shortcuts

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