Webhook-to-SSE gateway with hierarchical topic routing and signature verification
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}