Webhook-to-SSE gateway with hierarchical topic routing and signature verification
at main 305 lines 7.7 kB view raw
1package main 2 3import ( 4 "errors" 5 "os" 6 "path/filepath" 7 "testing" 8 "time" 9 10 "github.com/fsnotify/fsnotify" 11) 12 13func TestLoadConfiguration_valid(t *testing.T) { 14 dir := t.TempDir() 15 path := filepath.Join(dir, "wicket.yaml") 16 os.WriteFile(path, []byte(` 17paths: 18 github.com/chrisguidry/docketeer: 19 verify: hmac-sha256 20 secret: "webhook-secret" 21 signature_header: X-Hub-Signature-256 22 subscribe_secret: "sub-token" 23`), 0644) 24 25 cfg, err := LoadConfiguration(path) 26 if err != nil { 27 t.Fatalf("unexpected error: %v", err) 28 } 29 pc, ok := cfg.Paths["github.com/chrisguidry/docketeer"] 30 if !ok { 31 t.Fatal("expected path config for github.com/chrisguidry/docketeer") 32 } 33 if pc.Verify != "hmac-sha256" { 34 t.Errorf("expected hmac-sha256, got %s", pc.Verify) 35 } 36 if pc.Secret != "webhook-secret" { 37 t.Errorf("expected webhook-secret, got %s", pc.Secret) 38 } 39 if pc.SignatureHeader != "X-Hub-Signature-256" { 40 t.Errorf("expected X-Hub-Signature-256, got %s", pc.SignatureHeader) 41 } 42 if pc.SubscribeSecret != "sub-token" { 43 t.Errorf("expected sub-token, got %s", pc.SubscribeSecret) 44 } 45} 46 47func TestLoadConfiguration_missingFile(t *testing.T) { 48 _, err := LoadConfiguration("/nonexistent/wicket.yaml") 49 if err == nil { 50 t.Fatal("expected error for missing file") 51 } 52} 53 54func TestLoadConfiguration_empty(t *testing.T) { 55 dir := t.TempDir() 56 path := filepath.Join(dir, "wicket.yaml") 57 os.WriteFile(path, []byte(""), 0644) 58 59 cfg, err := LoadConfiguration(path) 60 if err != nil { 61 t.Fatalf("unexpected error: %v", err) 62 } 63 if cfg.Paths == nil { 64 cfg.Paths = make(map[string]PathConfiguration) 65 } 66 if len(cfg.Paths) != 0 { 67 t.Errorf("expected no paths, got %d", len(cfg.Paths)) 68 } 69} 70 71func TestLoadConfiguration_invalidYAML(t *testing.T) { 72 dir := t.TempDir() 73 path := filepath.Join(dir, "wicket.yaml") 74 os.WriteFile(path, []byte("not: valid: yaml: [[["), 0644) 75 76 _, err := LoadConfiguration(path) 77 if err == nil { 78 t.Fatal("expected error for invalid YAML") 79 } 80} 81 82func TestLookupSubscribeSecret_exactMatch(t *testing.T) { 83 cfg := &Configuration{ 84 Paths: map[string]PathConfiguration{ 85 "github.com/chrisguidry/docketeer": {SubscribeSecret: "token-123"}, 86 }, 87 } 88 secret := cfg.LookupSubscribeSecret("github.com/chrisguidry/docketeer") 89 if secret != "token-123" { 90 t.Errorf("expected token-123, got %s", secret) 91 } 92} 93 94func TestLookupSubscribeSecret_inheritsFromParent(t *testing.T) { 95 cfg := &Configuration{ 96 Paths: map[string]PathConfiguration{ 97 "github.com/chrisguidry": {SubscribeSecret: "parent-token"}, 98 }, 99 } 100 secret := cfg.LookupSubscribeSecret("github.com/chrisguidry/docketeer") 101 if secret != "parent-token" { 102 t.Errorf("expected parent-token, got %s", secret) 103 } 104} 105 106func TestLookupSubscribeSecret_noConfig(t *testing.T) { 107 cfg := &Configuration{ 108 Paths: map[string]PathConfiguration{}, 109 } 110 secret := cfg.LookupSubscribeSecret("github.com/chrisguidry/docketeer") 111 if secret != "" { 112 t.Errorf("expected empty string, got %s", secret) 113 } 114} 115 116func TestLookupVerification_inheritsFromParent(t *testing.T) { 117 cfg := &Configuration{ 118 Paths: map[string]PathConfiguration{ 119 "github.com": { 120 Verify: "hmac-sha256", 121 Secret: "webhook-secret", 122 SignatureHeader: "X-Hub-Signature-256", 123 }, 124 }, 125 } 126 127 pc := cfg.LookupVerification("github.com") 128 if pc == nil { 129 t.Fatal("expected path config for exact match") 130 } 131 132 pc = cfg.LookupVerification("github.com/org/repo") 133 if pc == nil { 134 t.Fatal("expected verification to inherit from parent") 135 } 136 if pc.Verify != "hmac-sha256" { 137 t.Errorf("expected hmac-sha256, got %s", pc.Verify) 138 } 139} 140 141func TestLookupVerification_childOverridesParent(t *testing.T) { 142 cfg := &Configuration{ 143 Paths: map[string]PathConfiguration{ 144 "github.com": { 145 Verify: "hmac-sha256", 146 Secret: "parent-secret", 147 SignatureHeader: "X-Hub-Signature-256", 148 }, 149 "github.com/org/repo": { 150 Verify: "hmac-sha1", 151 Secret: "child-secret", 152 SignatureHeader: "X-Hub-Signature", 153 }, 154 }, 155 } 156 157 pc := cfg.LookupVerification("github.com/org/repo") 158 if pc == nil { 159 t.Fatal("expected path config for child") 160 } 161 if pc.Secret != "child-secret" { 162 t.Errorf("expected child-secret, got %s", pc.Secret) 163 } 164 if pc.Verify != "hmac-sha1" { 165 t.Errorf("expected hmac-sha1, got %s", pc.Verify) 166 } 167} 168 169func TestWatchConfiguration_reloadsOnChange(t *testing.T) { 170 dir := t.TempDir() 171 path := filepath.Join(dir, "wicket.yaml") 172 os.WriteFile(path, []byte(` 173paths: 174 test/path: 175 subscribe_secret: "original" 176`), 0644) 177 178 reloaded := make(chan *Configuration, 1) 179 stop, err := WatchConfiguration(path, func(cfg *Configuration) { 180 reloaded <- cfg 181 }) 182 if err != nil { 183 t.Fatalf("unexpected error: %v", err) 184 } 185 defer stop() 186 187 os.WriteFile(path, []byte(` 188paths: 189 test/path: 190 subscribe_secret: "updated" 191`), 0644) 192 193 select { 194 case cfg := <-reloaded: 195 secret := cfg.LookupSubscribeSecret("test/path") 196 if secret != "updated" { 197 t.Errorf("expected updated secret, got %s", secret) 198 } 199 case <-time.After(2 * time.Second): 200 t.Fatal("timed out waiting for config reload") 201 } 202} 203 204func TestWatchConfiguration_ignoresInvalidYAML(t *testing.T) { 205 dir := t.TempDir() 206 path := filepath.Join(dir, "wicket.yaml") 207 os.WriteFile(path, []byte(` 208paths: 209 test/path: 210 subscribe_secret: "original" 211`), 0644) 212 213 reloaded := make(chan *Configuration, 1) 214 stop, err := WatchConfiguration(path, func(cfg *Configuration) { 215 reloaded <- cfg 216 }) 217 if err != nil { 218 t.Fatalf("unexpected error: %v", err) 219 } 220 defer stop() 221 222 os.WriteFile(path, []byte("not: valid: yaml: [[["), 0644) 223 224 select { 225 case <-reloaded: 226 t.Fatal("callback should not be called for invalid YAML") 227 case <-time.After(500 * time.Millisecond): 228 } 229} 230 231func TestWatchConfiguration_errorOnMissingFile(t *testing.T) { 232 _, err := WatchConfiguration("/nonexistent/wicket.yaml", func(cfg *Configuration) {}) 233 if err == nil { 234 t.Fatal("expected error watching nonexistent file") 235 } 236} 237 238func TestWatchConfiguration_errorCreatingWatcher(t *testing.T) { 239 original := newWatcher 240 newWatcher = func() (*fsnotify.Watcher, error) { 241 return nil, errors.New("simulated watcher error") 242 } 243 defer func() { newWatcher = original }() 244 245 _, err := WatchConfiguration("/any/path", func(cfg *Configuration) {}) 246 if err == nil { 247 t.Fatal("expected error when watcher creation fails") 248 } 249} 250 251func TestLoadConfiguration_expandsEnvVars(t *testing.T) { 252 t.Setenv("WICKET_TEST_SECRET", "expanded-secret") 253 dir := t.TempDir() 254 path := filepath.Join(dir, "wicket.yaml") 255 os.WriteFile(path, []byte(` 256paths: 257 test/path: 258 verify: hmac-sha256 259 secret: "${WICKET_TEST_SECRET}" 260`), 0644) 261 262 cfg, err := LoadConfiguration(path) 263 if err != nil { 264 t.Fatalf("unexpected error: %v", err) 265 } 266 pc := cfg.Paths["test/path"] 267 if pc.Secret != "expanded-secret" { 268 t.Errorf("expected expanded-secret, got %s", pc.Secret) 269 } 270} 271 272func TestLoadConfiguration_unexpandedEnvVar(t *testing.T) { 273 dir := t.TempDir() 274 path := filepath.Join(dir, "wicket.yaml") 275 os.WriteFile(path, []byte(` 276paths: 277 test/path: 278 secret: "${WICKET_UNSET_VAR_xyz}" 279`), 0644) 280 281 cfg, err := LoadConfiguration(path) 282 if err != nil { 283 t.Fatalf("unexpected error: %v", err) 284 } 285 pc := cfg.Paths["test/path"] 286 if pc.Secret != "" { 287 t.Errorf("expected empty string for unset var, got %s", pc.Secret) 288 } 289} 290 291func TestLookupSubscribeSecret_nilConfig(t *testing.T) { 292 var cfg *Configuration 293 secret := cfg.LookupSubscribeSecret("any/path") 294 if secret != "" { 295 t.Errorf("expected empty string from nil config, got %s", secret) 296 } 297} 298 299func TestLookupVerification_nilConfig(t *testing.T) { 300 var cfg *Configuration 301 pc := cfg.LookupVerification("any/path") 302 if pc != nil { 303 t.Error("expected nil from nil config") 304 } 305}