1package config
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "github.com/bnema/zerowrap"
11 "github.com/spf13/viper"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19func testContext() context.Context {
20 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
21}
22
23func requireRoute(t *testing.T, routes map[string]routeConfig, domainName string) routeConfig {
24 t.Helper()
25 route, ok := routes[domainName]
26 require.True(t, ok, "route %q not found", domainName)
27 return route
28}
29
30func setupServiceWithCoexistingLegacyRoute(t *testing.T, canonicalImage, legacyImage string) (*Service, context.Context) {
31 t.Helper()
32
33 tmpDir := t.TempDir()
34 configFile := filepath.Join(tmpDir, "gordon.toml")
35 err := os.WriteFile(configFile, []byte(fmt.Sprintf("[routes]\n\"app.example.com\" = %q\n\"http://app.example.com\" = %q\n", canonicalImage, legacyImage)), 0600)
36 require.NoError(t, err)
37
38 v := viper.New()
39 v.SetConfigFile(configFile)
40 v.Set("routes", map[string]any{
41 "app.example.com": canonicalImage,
42 "http://app.example.com": legacyImage,
43 })
44
45 eventBus := mocks.NewMockEventPublisher(t)
46 svc := NewService(v, eventBus)
47 ctx := testContext()
48 require.NoError(t, svc.Load(ctx))
49
50 svc.mu.Lock()
51 if svc.config.Routes == nil {
52 svc.config.Routes = make(map[string]routeConfig)
53 }
54 svc.config.Routes["app.example.com"] = routeConfig{Image: canonicalImage, HTTPS: true}
55 svc.config.Routes["http://app.example.com"] = routeConfig{Image: legacyImage, HTTPS: false}
56 svc.mu.Unlock()
57
58 return svc, ctx
59}
60
61func TestService_Load(t *testing.T) {
62 v := viper.New()
63 v.Set("server.port", 8080)
64 v.Set("server.registry_port", 5000)
65 v.Set("server.registry_domain", "registry.example.com")
66 v.Set("server.data_dir", "/var/gordon")
67 v.Set("auto_route.enabled", true)
68 v.Set("network_isolation.enabled", true)
69 v.Set("network_isolation.network_prefix", "gordon")
70 v.Set("auth.enabled", true)
71 v.Set("auth.username", "admin")
72 v.Set("auth.password", "secret")
73 v.Set("volumes.auto_create", true)
74 v.Set("volumes.prefix", "gordon")
75 v.Set("volumes.preserve", false)
76 v.Set("routes", map[string]interface{}{
77 "app.example.com": "myapp:latest",
78 })
79
80 eventBus := mocks.NewMockEventPublisher(t)
81 svc := NewService(v, eventBus)
82 ctx := testContext()
83
84 err := svc.Load(ctx)
85
86 assert.NoError(t, err)
87 assert.Equal(t, 8080, svc.GetServerPort())
88 assert.Equal(t, 5000, svc.GetRegistryPort())
89 assert.Equal(t, "registry.example.com", svc.GetRegistryDomain())
90 assert.Equal(t, "/var/gordon", svc.GetDataDir())
91 assert.True(t, svc.IsAutoRouteEnabled())
92 assert.True(t, svc.IsNetworkIsolationEnabled())
93 assert.Equal(t, "gordon", svc.GetNetworkPrefix())
94
95 autoCreate, prefix, preserve := svc.GetVolumeConfig()
96 assert.True(t, autoCreate)
97 assert.Equal(t, "gordon", prefix)
98 assert.False(t, preserve)
99}
100
101func TestService_Load_CanonicalInlineRoute(t *testing.T) {
102 tmpDir := t.TempDir()
103 configFile := filepath.Join(tmpDir, "gordon.toml")
104 err := os.WriteFile(configFile, []byte(`[routes]
105"insecure.example.com" = { image = "myapp:latest", https = false }
106`), 0600)
107 require.NoError(t, err)
108
109 v := viper.New()
110 v.SetConfigFile(configFile)
111 require.NoError(t, v.ReadInConfig())
112
113 eventBus := mocks.NewMockEventPublisher(t)
114 svc := NewService(v, eventBus)
115 ctx := testContext()
116
117 err = svc.Load(ctx)
118 require.NoError(t, err)
119
120 route, err := svc.GetRoute(ctx, "insecure.example.com")
121 require.NoError(t, err)
122 assert.Equal(t, "myapp:latest", route.Image)
123 assert.False(t, route.HTTPS)
124}
125
126func TestService_Load_CanonicalInlineRouteDefaultsHTTPS(t *testing.T) {
127 tmpDir := t.TempDir()
128 configFile := filepath.Join(tmpDir, "gordon.toml")
129 err := os.WriteFile(configFile, []byte(`[routes]
130"secure.example.com" = { image = "myapp:latest" }
131`), 0600)
132 require.NoError(t, err)
133
134 v := viper.New()
135 v.SetConfigFile(configFile)
136 require.NoError(t, v.ReadInConfig())
137
138 eventBus := mocks.NewMockEventPublisher(t)
139 svc := NewService(v, eventBus)
140 ctx := testContext()
141
142 err = svc.Load(ctx)
143 require.NoError(t, err)
144
145 route, err := svc.GetRoute(ctx, "secure.example.com")
146 require.NoError(t, err)
147 assert.Equal(t, "myapp:latest", route.Image)
148 assert.True(t, route.HTTPS)
149}
150
151func TestService_Reload_InvalidRouteKeyPreservesPreviousState(t *testing.T) {
152 tmpDir := t.TempDir()
153 configFile := filepath.Join(tmpDir, "gordon.toml")
154 initialConfig := `[routes]
155"app.example.com" = "myapp:v1"
156`
157 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
158 require.NoError(t, err)
159
160 v := viper.New()
161 v.SetConfigFile(configFile)
162 require.NoError(t, v.ReadInConfig())
163
164 eventBus := mocks.NewMockEventPublisher(t)
165 svc := NewService(v, eventBus)
166 ctx := testContext()
167
168 err = svc.Load(ctx)
169 require.NoError(t, err)
170
171 updatedConfig := `[routes]
172"localhost" = "myapp:v2"
173`
174 err = os.WriteFile(configFile, []byte(updatedConfig), 0600)
175 require.NoError(t, err)
176
177 err = svc.Reload(ctx)
178 require.Error(t, err)
179
180 route, getErr := svc.GetRoute(ctx, "app.example.com")
181 require.NoError(t, getErr)
182 assert.Equal(t, "myapp:v1", route.Image)
183 assert.True(t, route.HTTPS)
184}
185
186func TestService_GetRoutes_DeduplicatesCanonicalAndLegacyEntries(t *testing.T) {
187 svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "canonical:v2", "legacy:v1")
188
189 routes := svc.GetRoutes(ctx)
190
191 require.Len(t, routes, 1)
192 assert.Equal(t, "app.example.com", routes[0].Domain)
193 assert.Equal(t, "canonical:v2", routes[0].Image)
194 assert.True(t, routes[0].HTTPS)
195}
196
197func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *testing.T) {
198 v := viper.New()
199 v.Set("server.registry_domain", "registry.example.com")
200 v.Set("routes", map[string]any{})
201 eventBus := mocks.NewMockEventPublisher(t)
202 svc := NewService(v, eventBus)
203 ctx := testContext()
204
205 _ = svc.Load(ctx)
206 svc.mu.Lock()
207 svc.config.Routes = make(map[string]routeConfig)
208 svc.config.Routes["app.example.com"] = routeConfig{Image: "registry.example.com/myapp:v2", HTTPS: true}
209 svc.config.Routes["http://app.example.com"] = routeConfig{Image: "myapp:v1", HTTPS: false}
210 svc.mu.Unlock()
211
212 routes := svc.FindRoutesByImage(ctx, "myapp")
213
214 require.Len(t, routes, 1)
215 assert.Equal(t, "app.example.com", routes[0].Domain)
216 assert.Equal(t, "registry.example.com/myapp:v2", routes[0].Image)
217 assert.True(t, routes[0].HTTPS)
218}
219
220func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
221 tmpDir := t.TempDir()
222 configFile := filepath.Join(tmpDir, "gordon.toml")
223 err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
224 require.NoError(t, err)
225
226 v := viper.New()
227 v.SetConfigFile(configFile)
228 eventBus := mocks.NewMockEventPublisher(t)
229 svc := NewService(v, eventBus)
230 ctx := testContext()
231
232 _ = svc.Load(ctx)
233
234 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest", HTTPS: false})
235 require.NoError(t, err)
236
237 routeCfg := requireRoute(t, svc.GetConfig().Routes, "new.example.com")
238 assert.Equal(t, "newapp:latest", routeCfg.Image)
239 assert.False(t, routeCfg.HTTPS)
240}
241
242func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
243 tmpDir := t.TempDir()
244 configFile := filepath.Join(tmpDir, "gordon.toml")
245 initialConfig := `[routes]
246"secure.example.com" = "secure:v1"
247"http://insecure.example.com" = "insecure:v1"
248`
249 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
250 require.NoError(t, err)
251
252 v := viper.New()
253 v.SetConfigFile(configFile)
254 require.NoError(t, v.ReadInConfig())
255
256 eventBus := mocks.NewMockEventPublisher(t)
257 svc := NewService(v, eventBus)
258 ctx := testContext()
259
260 err = svc.Load(ctx)
261 require.NoError(t, err)
262
263 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "new:v1"})
264 require.NoError(t, err)
265
266 content, err := os.ReadFile(configFile)
267 require.NoError(t, err)
268 text := string(content)
269 assert.Contains(t, text, `"secure.example.com" = { image = "secure:v1", https = true }`)
270 assert.Contains(t, text, `"insecure.example.com" = { image = "insecure:v1", https = false }`)
271 assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`)
272 assert.NotContains(t, text, "http://insecure.example.com")
273 assert.Contains(t, text, "[routes]")
274}
275
276func TestService_Reload(t *testing.T) {
277 t.Run("success - picks up config file changes", func(t *testing.T) {
278 // Create temp config file
279 tmpDir := t.TempDir()
280 configFile := filepath.Join(tmpDir, "gordon.toml")
281 initialConfig := `[server]
282port = 8080
283
284[routes]
285"app.example.com" = "myapp:v1"
286`
287 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
288 require.NoError(t, err)
289
290 v := viper.New()
291 v.SetConfigFile(configFile)
292 err = v.ReadInConfig()
293 require.NoError(t, err)
294
295 eventBus := mocks.NewMockEventPublisher(t)
296 svc := NewService(v, eventBus)
297 ctx := testContext()
298
299 // Initial load
300 err = svc.Load(ctx)
301 require.NoError(t, err)
302 assert.Equal(t, 8080, svc.GetServerPort())
303 route := requireRoute(t, svc.GetConfig().Routes, "app.example.com")
304 assert.Equal(t, "myapp:v1", route.Image)
305 assert.True(t, route.HTTPS)
306
307 // Modify config file on disk
308 updatedConfig := `[server]
309port = 9090
310
311[routes]
312"app.example.com" = "myapp:v2"
313"new.example.com" = "newapp:latest"
314`
315 err = os.WriteFile(configFile, []byte(updatedConfig), 0600)
316 require.NoError(t, err)
317
318 // Reload should pick up new values
319 err = svc.Reload(ctx)
320 require.NoError(t, err)
321
322 assert.Equal(t, 9090, svc.GetServerPort())
323 route = requireRoute(t, svc.GetConfig().Routes, "app.example.com")
324 assert.Equal(t, "myapp:v2", route.Image)
325 assert.True(t, route.HTTPS)
326 route = requireRoute(t, svc.GetConfig().Routes, "new.example.com")
327 assert.Equal(t, "newapp:latest", route.Image)
328 assert.True(t, route.HTTPS)
329 })
330
331 t.Run("error - config file not found", func(t *testing.T) {
332 tmpDir := t.TempDir()
333 configFile := filepath.Join(tmpDir, "gordon.toml")
334
335 // Create config, load it, then delete it
336 err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
337 require.NoError(t, err)
338
339 v := viper.New()
340 v.SetConfigFile(configFile)
341 err = v.ReadInConfig()
342 require.NoError(t, err)
343
344 eventBus := mocks.NewMockEventPublisher(t)
345 svc := NewService(v, eventBus)
346 ctx := testContext()
347
348 err = svc.Load(ctx)
349 require.NoError(t, err)
350
351 // Delete the config file
352 err = os.Remove(configFile)
353 require.NoError(t, err)
354
355 // Reload should fail
356 err = svc.Reload(ctx)
357 assert.Error(t, err)
358 assert.Contains(t, err.Error(), "failed to read config file")
359 })
360
361 t.Run("error - invalid config syntax", func(t *testing.T) {
362 tmpDir := t.TempDir()
363 configFile := filepath.Join(tmpDir, "gordon.toml")
364
365 // Create valid config initially
366 err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
367 require.NoError(t, err)
368
369 v := viper.New()
370 v.SetConfigFile(configFile)
371 err = v.ReadInConfig()
372 require.NoError(t, err)
373
374 eventBus := mocks.NewMockEventPublisher(t)
375 svc := NewService(v, eventBus)
376 ctx := testContext()
377
378 err = svc.Load(ctx)
379 require.NoError(t, err)
380
381 // Write invalid TOML
382 err = os.WriteFile(configFile, []byte("[server\nport = invalid syntax"), 0600)
383 require.NoError(t, err)
384
385 // Reload should fail
386 err = svc.Reload(ctx)
387 assert.Error(t, err)
388 })
389}
390
391func TestService_GetRoutes(t *testing.T) {
392 v := viper.New()
393 v.Set("routes", map[string]interface{}{
394 "app1.example.com": "myapp:latest",
395 "app2.example.com": "otherapp:v2",
396 "http://insecure.example": "insecureapp:latest",
397 })
398
399 eventBus := mocks.NewMockEventPublisher(t)
400 svc := NewService(v, eventBus)
401 ctx := testContext()
402
403 _ = svc.Load(ctx)
404
405 routes := svc.GetRoutes(ctx)
406
407 assert.Len(t, routes, 3)
408
409 // Check routes (order is not guaranteed)
410 routeMap := make(map[string]domain.Route)
411 for _, r := range routes {
412 routeMap[r.Domain] = r
413 }
414
415 assert.Equal(t, "myapp:latest", routeMap["app1.example.com"].Image)
416 assert.True(t, routeMap["app1.example.com"].HTTPS)
417
418 assert.Equal(t, "otherapp:v2", routeMap["app2.example.com"].Image)
419 assert.True(t, routeMap["app2.example.com"].HTTPS)
420
421 assert.Equal(t, "insecureapp:latest", routeMap["insecure.example"].Image)
422 assert.False(t, routeMap["insecure.example"].HTTPS)
423}
424
425func TestService_GetRoute(t *testing.T) {
426 v := viper.New()
427 v.Set("routes", map[string]interface{}{
428 "app.example.com": "myapp:latest",
429 "http://insecure.example": "insecureapp:latest",
430 })
431
432 eventBus := mocks.NewMockEventPublisher(t)
433 svc := NewService(v, eventBus)
434 ctx := testContext()
435
436 _ = svc.Load(ctx)
437
438 t.Run("existing route", func(t *testing.T) {
439 route, err := svc.GetRoute(ctx, "app.example.com")
440 require.NoError(t, err)
441 assert.Equal(t, "app.example.com", route.Domain)
442 assert.Equal(t, "myapp:latest", route.Image)
443 assert.True(t, route.HTTPS)
444 })
445
446 t.Run("legacy http route found via fallback", func(t *testing.T) {
447 route, err := svc.GetRoute(ctx, "insecure.example")
448 require.NoError(t, err)
449 assert.Equal(t, "insecure.example", route.Domain)
450 assert.Equal(t, "insecureapp:latest", route.Image)
451 assert.False(t, route.HTTPS)
452 })
453
454 t.Run("non-existent route", func(t *testing.T) {
455 route, err := svc.GetRoute(ctx, "notfound.example.com")
456 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
457 assert.Nil(t, route)
458 })
459}
460
461func TestLoadCanonicalRoutes_RejectsDuplicateCanonicalRouteKeys(t *testing.T) {
462 _, err := loadCanonicalRoutes(map[string]any{
463 "App.Example.com": "app:v1",
464 "app.example.com": "app:v2",
465 })
466
467 require.Error(t, err)
468 assert.Contains(t, err.Error(), "duplicate route key")
469}
470
471func TestService_Load_RejectsExternalRouteConflictingWithRoute(t *testing.T) {
472 v := viper.New()
473 v.Set("routes", map[string]any{
474 "App.Example.com": "app:latest",
475 })
476 v.Set("external_routes", map[string]any{
477 "app.example.com": "203.0.113.10:5000",
478 })
479
480 svc := NewService(v, mocks.NewMockEventPublisher(t))
481 err := svc.Load(testContext())
482
483 require.Error(t, err)
484 assert.Contains(t, err.Error(), "conflicts with configured route")
485}
486
487func TestLoadExternalRoutes_RejectsInvalidAndDuplicateKeys(t *testing.T) {
488 t.Run("invalid", func(t *testing.T) {
489 _, err := loadExternalRoutes(map[string]any{
490 "bad.example.com:8080": "203.0.113.10:5000",
491 })
492
493 require.Error(t, err)
494 assert.Contains(t, err.Error(), "invalid external route key")
495 })
496
497 t.Run("duplicate canonical", func(t *testing.T) {
498 _, err := loadExternalRoutes(map[string]any{
499 "Reg.Example.com": "203.0.113.10:5000",
500 "reg.example.com": "203.0.113.11:5000",
501 })
502
503 require.Error(t, err)
504 assert.Contains(t, err.Error(), "duplicate external route key")
505 })
506}
507
508func TestService_AddRoute_RejectsExternalRouteConflict(t *testing.T) {
509 v := viper.New()
510 v.Set("external_routes", map[string]any{
511 "app.example.com": "203.0.113.10:5000",
512 })
513
514 svc := NewService(v, mocks.NewMockEventPublisher(t))
515 ctx := testContext()
516 require.NoError(t, svc.Load(ctx))
517
518 err := svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest"})
519
520 require.Error(t, err)
521 assert.ErrorIs(t, err, domain.ErrRouteConflict)
522 assert.Contains(t, err.Error(), "conflicts with external route")
523}
524
525func TestService_AddRoute_CanonicalizesDomainCaseAndRejectsInvalidAuthority(t *testing.T) {
526 configFile := filepath.Join(t.TempDir(), "gordon.toml")
527 require.NoError(t, os.WriteFile(configFile, []byte("[routes]\n"), 0600))
528 v := viper.New()
529 v.SetConfigFile(configFile)
530 svc := NewService(v, mocks.NewMockEventPublisher(t))
531 ctx := testContext()
532 require.NoError(t, svc.Load(ctx))
533
534 require.NoError(t, svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest", HTTPS: true}))
535 route, err := svc.GetRoute(ctx, "app.example.com")
536 require.NoError(t, err)
537 assert.Equal(t, "app.example.com", route.Domain)
538 _, err = svc.GetRoute(ctx, "APP.EXAMPLE.COM")
539 require.NoError(t, err)
540
541 err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com:8080", Image: "app:latest"})
542 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
543 err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com.", Image: "app:latest"})
544 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
545}
546
547func TestService_AddRoute(t *testing.T) {
548 t.Run("success", func(t *testing.T) {
549 // Create temp config file for Save() to work
550 tmpDir := t.TempDir()
551 configFile := filepath.Join(tmpDir, "gordon.toml")
552 err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
553 require.NoError(t, err)
554
555 v := viper.New()
556 v.SetConfigFile(configFile)
557 eventBus := mocks.NewMockEventPublisher(t)
558 svc := NewService(v, eventBus)
559 ctx := testContext()
560
561 _ = svc.Load(ctx)
562
563 route := domain.Route{
564 Domain: "new.example.com",
565 Image: "newapp:latest",
566 HTTPS: true,
567 }
568
569 err = svc.AddRoute(ctx, route)
570
571 assert.NoError(t, err)
572
573 // Verify route was added
574 config := svc.GetConfig()
575 routeCfg := requireRoute(t, config.Routes, "new.example.com")
576 assert.Equal(t, "newapp:latest", routeCfg.Image)
577 assert.True(t, routeCfg.HTTPS)
578 })
579
580 t.Run("zero-value route stores HTTPS false in canonical form", func(t *testing.T) {
581 tmpDir := t.TempDir()
582 configFile := filepath.Join(tmpDir, "gordon.toml")
583 err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
584 require.NoError(t, err)
585
586 v := viper.New()
587 v.SetConfigFile(configFile)
588 eventBus := mocks.NewMockEventPublisher(t)
589 svc := NewService(v, eventBus)
590 ctx := testContext()
591 _ = svc.Load(ctx)
592
593 // Zero-value HTTPS field (false) should be preserved
594 route := domain.Route{
595 Domain: "app.example.com",
596 Image: "myapp:latest",
597 }
598 err = svc.AddRoute(ctx, route)
599 require.NoError(t, err)
600
601 config := svc.GetConfig()
602 routeCfg := requireRoute(t, config.Routes, "app.example.com")
603 assert.Equal(t, "myapp:latest", routeCfg.Image,
604 "zero-value route should store under plain domain key")
605 assert.False(t, routeCfg.HTTPS)
606 _, hasHTTPKey := config.Routes["http://app.example.com"]
607 assert.False(t, hasHTTPKey, "zero-value route should NOT get http:// prefix")
608
609 // Verify it round-trips as HTTPS=false via GetRoutes
610 routes := svc.GetRoutes(ctx)
611 require.Len(t, routes, 1)
612 assert.False(t, routes[0].HTTPS, "zero-value route should be treated as HTTP")
613 })
614
615 t.Run("HTTPS explicitly true stores under plain domain key", func(t *testing.T) {
616 tmpDir := t.TempDir()
617 configFile := filepath.Join(tmpDir, "gordon.toml")
618 err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
619 require.NoError(t, err)
620
621 v := viper.New()
622 v.SetConfigFile(configFile)
623 eventBus := mocks.NewMockEventPublisher(t)
624 svc := NewService(v, eventBus)
625 ctx := testContext()
626 _ = svc.Load(ctx)
627
628 // Explicitly set HTTPS: true — should store under plain domain key
629 route := domain.Route{
630 Domain: "secure.example.com",
631 Image: "myapp:latest",
632 HTTPS: true,
633 }
634 err = svc.AddRoute(ctx, route)
635 require.NoError(t, err)
636
637 config := svc.GetConfig()
638 routeCfg := requireRoute(t, config.Routes, "secure.example.com")
639 assert.Equal(t, "myapp:latest", routeCfg.Image,
640 "HTTPS=true route should store under plain domain key")
641 assert.True(t, routeCfg.HTTPS)
642 })
643
644 t.Run("idempotent same domain and image saves once", func(t *testing.T) {
645 tmpDir := t.TempDir()
646 configFile := filepath.Join(tmpDir, "gordon.toml")
647 err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
648 require.NoError(t, err)
649
650 v := viper.New()
651 v.SetConfigFile(configFile)
652 eventBus := mocks.NewMockEventPublisher(t)
653 svc := NewService(v, eventBus)
654 ctx := testContext()
655
656 _ = svc.Load(ctx)
657
658 route := domain.Route{
659 Domain: "new.example.com",
660 Image: "newapp:latest",
661 HTTPS: true,
662 }
663
664 err = svc.AddRoute(ctx, route)
665 require.NoError(t, err)
666
667 before, err := os.Stat(configFile)
668 require.NoError(t, err)
669
670 err = svc.AddRoute(ctx, route)
671 require.NoError(t, err)
672
673 after, err := os.Stat(configFile)
674 require.NoError(t, err)
675
676 assert.Equal(t, before.ModTime(), after.ModTime())
677 routeCfg := requireRoute(t, svc.GetConfig().Routes, "new.example.com")
678 assert.Equal(t, "newapp:latest", routeCfg.Image)
679 assert.True(t, routeCfg.HTTPS)
680 })
681
682 t.Run("empty domain", func(t *testing.T) {
683 v := viper.New()
684 eventBus := mocks.NewMockEventPublisher(t)
685 svc := NewService(v, eventBus)
686 ctx := testContext()
687
688 route := domain.Route{
689 Domain: "",
690 Image: "myapp:latest",
691 }
692
693 err := svc.AddRoute(ctx, route)
694 assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
695 })
696
697 t.Run("empty image", func(t *testing.T) {
698 v := viper.New()
699 eventBus := mocks.NewMockEventPublisher(t)
700 svc := NewService(v, eventBus)
701 ctx := testContext()
702
703 route := domain.Route{
704 Domain: "example.com",
705 Image: "",
706 }
707
708 err := svc.AddRoute(ctx, route)
709 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
710 })
711
712 t.Run("invalid domain - IP address", func(t *testing.T) {
713 v := viper.New()
714 eventBus := mocks.NewMockEventPublisher(t)
715 svc := NewService(v, eventBus)
716 ctx := testContext()
717
718 route := domain.Route{
719 Domain: "192.168.1.1",
720 Image: "myapp:latest",
721 }
722
723 err := svc.AddRoute(ctx, route)
724 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
725 })
726
727 t.Run("invalid domain - localhost", func(t *testing.T) {
728 v := viper.New()
729 eventBus := mocks.NewMockEventPublisher(t)
730 svc := NewService(v, eventBus)
731 ctx := testContext()
732
733 route := domain.Route{
734 Domain: "localhost",
735 Image: "myapp:latest",
736 }
737
738 err := svc.AddRoute(ctx, route)
739 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
740 })
741
742 t.Run("invalid domain - internal TLD", func(t *testing.T) {
743 v := viper.New()
744 eventBus := mocks.NewMockEventPublisher(t)
745 svc := NewService(v, eventBus)
746 ctx := testContext()
747
748 route := domain.Route{
749 Domain: "myapp.local",
750 Image: "myapp:latest",
751 }
752
753 err := svc.AddRoute(ctx, route)
754 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
755 })
756
757 t.Run("invalid domain - with port", func(t *testing.T) {
758 v := viper.New()
759 eventBus := mocks.NewMockEventPublisher(t)
760 svc := NewService(v, eventBus)
761 ctx := testContext()
762
763 route := domain.Route{
764 Domain: "example.com:8080",
765 Image: "myapp:latest",
766 }
767
768 err := svc.AddRoute(ctx, route)
769 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
770 })
771
772 t.Run("reconciles coexisting legacy http key", func(t *testing.T) {
773 svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "myapp:v1", "myapp:old")
774 preConfig := svc.GetConfig()
775 requireRoute(t, preConfig.Routes, "app.example.com")
776 requireRoute(t, preConfig.Routes, "http://app.example.com")
777
778 route := domain.Route{
779 Domain: "app.example.com",
780 Image: "myapp:v2",
781 HTTPS: true,
782 }
783
784 err := svc.AddRoute(ctx, route)
785 assert.NoError(t, err)
786
787 config := svc.GetConfig()
788 routeCfg := requireRoute(t, config.Routes, "app.example.com")
789 assert.Equal(t, "myapp:v2", routeCfg.Image,
790 "canonical route should be updated")
791 assert.True(t, routeCfg.HTTPS)
792 _, exists := config.Routes["http://app.example.com"]
793 assert.False(t, exists, "legacy http route key should be removed during reconciliation")
794 })
795}
796
797func TestService_UpdateRoute(t *testing.T) {
798 t.Run("success", func(t *testing.T) {
799 // Create temp config file for Save() to work
800 tmpDir := t.TempDir()
801 configFile := filepath.Join(tmpDir, "gordon.toml")
802 err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600)
803 require.NoError(t, err)
804
805 v := viper.New()
806 v.SetConfigFile(configFile)
807 v.Set("routes", map[string]any{
808 "app.example.com": "myapp:v1",
809 })
810
811 eventBus := mocks.NewMockEventPublisher(t)
812 svc := NewService(v, eventBus)
813 ctx := testContext()
814
815 _ = svc.Load(ctx)
816
817 route := domain.Route{
818 Domain: "app.example.com",
819 Image: "myapp:v2",
820 HTTPS: true,
821 }
822
823 err = svc.UpdateRoute(ctx, route)
824
825 assert.NoError(t, err)
826
827 config := svc.GetConfig()
828 routeCfg := requireRoute(t, config.Routes, "app.example.com")
829 assert.Equal(t, "myapp:v2", routeCfg.Image)
830 assert.True(t, routeCfg.HTTPS)
831 })
832
833 t.Run("updates https setting", func(t *testing.T) {
834 tmpDir := t.TempDir()
835 configFile := filepath.Join(tmpDir, "gordon.toml")
836 err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600)
837 require.NoError(t, err)
838
839 v := viper.New()
840 v.SetConfigFile(configFile)
841 v.Set("routes", map[string]any{
842 "app.example.com": "myapp:v1",
843 })
844
845 eventBus := mocks.NewMockEventPublisher(t)
846 svc := NewService(v, eventBus)
847 ctx := testContext()
848
849 _ = svc.Load(ctx)
850
851 route := domain.Route{
852 Domain: "app.example.com",
853 Image: "myapp:v2",
854 HTTPS: false,
855 }
856
857 err = svc.UpdateRoute(ctx, route)
858 require.NoError(t, err)
859
860 routeCfg := requireRoute(t, svc.GetConfig().Routes, "app.example.com")
861 assert.Equal(t, "myapp:v2", routeCfg.Image)
862 assert.False(t, routeCfg.HTTPS)
863
864 content, err := os.ReadFile(configFile)
865 require.NoError(t, err)
866 assert.Contains(t, string(content), `"app.example.com" = { image = "myapp:v2", https = false }`)
867 })
868
869 t.Run("not found", func(t *testing.T) {
870 v := viper.New()
871 eventBus := mocks.NewMockEventPublisher(t)
872 svc := NewService(v, eventBus)
873 ctx := testContext()
874
875 _ = svc.Load(ctx)
876
877 route := domain.Route{
878 Domain: "nonexistent.example.com",
879 Image: "myapp:latest",
880 }
881
882 err := svc.UpdateRoute(ctx, route)
883 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
884 })
885
886 t.Run("empty domain", func(t *testing.T) {
887 v := viper.New()
888 eventBus := mocks.NewMockEventPublisher(t)
889 svc := NewService(v, eventBus)
890 ctx := testContext()
891
892 route := domain.Route{
893 Domain: "",
894 Image: "myapp:latest",
895 }
896
897 err := svc.UpdateRoute(ctx, route)
898 assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
899 })
900
901 t.Run("empty image", func(t *testing.T) {
902 v := viper.New()
903 eventBus := mocks.NewMockEventPublisher(t)
904 svc := NewService(v, eventBus)
905 ctx := testContext()
906
907 route := domain.Route{
908 Domain: "example.com",
909 Image: "",
910 }
911
912 err := svc.UpdateRoute(ctx, route)
913 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
914 })
915
916 t.Run("invalid domain - IP address", func(t *testing.T) {
917 v := viper.New()
918 v.Set("routes", map[string]any{
919 "192.168.1.1": "myapp:v1",
920 })
921 eventBus := mocks.NewMockEventPublisher(t)
922 svc := NewService(v, eventBus)
923 ctx := testContext()
924
925 _ = svc.Load(ctx)
926
927 route := domain.Route{
928 Domain: "192.168.1.1",
929 Image: "myapp:v2",
930 }
931
932 err := svc.UpdateRoute(ctx, route)
933 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
934 })
935
936 t.Run("invalid domain - localhost", func(t *testing.T) {
937 v := viper.New()
938 v.Set("routes", map[string]any{
939 "localhost": "myapp:v1",
940 })
941 eventBus := mocks.NewMockEventPublisher(t)
942 svc := NewService(v, eventBus)
943 ctx := testContext()
944
945 _ = svc.Load(ctx)
946
947 route := domain.Route{
948 Domain: "localhost",
949 Image: "myapp:v2",
950 }
951
952 err := svc.UpdateRoute(ctx, route)
953 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
954 })
955
956 t.Run("insecure route found via http prefix fallback", func(t *testing.T) {
957 tmpDir := t.TempDir()
958 configFile := filepath.Join(tmpDir, "gordon.toml")
959 err := os.WriteFile(configFile, []byte("[routes]\n\"http://insecure.example.com\" = \"myapp:v1\"\n"), 0600)
960 require.NoError(t, err)
961
962 v := viper.New()
963 v.SetConfigFile(configFile)
964 v.Set("routes", map[string]any{
965 "http://insecure.example.com": "myapp:v1",
966 })
967 eventBus := mocks.NewMockEventPublisher(t)
968 svc := NewService(v, eventBus)
969 ctx := testContext()
970 _ = svc.Load(ctx)
971
972 // Caller uses clean domain for update
973 route := domain.Route{
974 Domain: "insecure.example.com",
975 Image: "myapp:v2",
976 HTTPS: false,
977 }
978
979 err = svc.UpdateRoute(ctx, route)
980 assert.NoError(t, err, "UpdateRoute should find insecure route via http:// fallback")
981
982 config := svc.GetConfig()
983 routeCfg := requireRoute(t, config.Routes, "insecure.example.com")
984 assert.Equal(t, "myapp:v2", routeCfg.Image,
985 "insecure route update should preserve the plain hostname key")
986 assert.False(t, routeCfg.HTTPS)
987 })
988
989 t.Run("reconciles coexisting legacy http key", func(t *testing.T) {
990 svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "myapp:v1", "myapp:old")
991 preConfig := svc.GetConfig()
992 requireRoute(t, preConfig.Routes, "app.example.com")
993 requireRoute(t, preConfig.Routes, "http://app.example.com")
994
995 route := domain.Route{
996 Domain: "app.example.com",
997 Image: "myapp:v2",
998 HTTPS: true,
999 }
1000
1001 err := svc.UpdateRoute(ctx, route)
1002 assert.NoError(t, err)
1003
1004 config := svc.GetConfig()
1005 routeCfg := requireRoute(t, config.Routes, "app.example.com")
1006 assert.Equal(t, "myapp:v2", routeCfg.Image,
1007 "canonical route should be updated")
1008 assert.True(t, routeCfg.HTTPS)
1009 _, exists := config.Routes["http://app.example.com"]
1010 assert.False(t, exists, "legacy http route key should be removed during reconciliation")
1011 })
1012}
1013
1014func TestService_RemoveRoute(t *testing.T) {
1015 // Create temp config file for Save() to work
1016 tmpDir := t.TempDir()
1017 configFile := filepath.Join(tmpDir, "gordon.toml")
1018 err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
1019 require.NoError(t, err)
1020
1021 v := viper.New()
1022 v.SetConfigFile(configFile)
1023 v.Set("routes", map[string]interface{}{
1024 "app.example.com": "myapp:latest",
1025 })
1026
1027 eventBus := mocks.NewMockEventPublisher(t)
1028 svc := NewService(v, eventBus)
1029 ctx := testContext()
1030
1031 _ = svc.Load(ctx)
1032
1033 err = svc.RemoveRoute(ctx, "app.example.com")
1034
1035 assert.NoError(t, err)
1036
1037 config := svc.GetConfig()
1038 _, exists := config.Routes["app.example.com"]
1039 assert.False(t, exists)
1040}
1041
1042func TestService_RemoveRoute_InvalidDomain(t *testing.T) {
1043 v := viper.New()
1044 eventBus := mocks.NewMockEventPublisher(t)
1045 svc := NewService(v, eventBus)
1046 ctx := testContext()
1047
1048 err := svc.RemoveRoute(ctx, "localhost")
1049 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
1050}
1051
1052func TestService_RemoveRoute_NotFound(t *testing.T) {
1053 v := viper.New()
1054 eventBus := mocks.NewMockEventPublisher(t)
1055 svc := NewService(v, eventBus)
1056 ctx := testContext()
1057
1058 _ = svc.Load(ctx)
1059
1060 err := svc.RemoveRoute(ctx, "nonexistent.example.com")
1061
1062 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
1063}
1064
1065func TestService_RemoveRoute_InsecureFallback(t *testing.T) {
1066 // Setup: insecure route stored with http:// prefix
1067 tmpDir := t.TempDir()
1068 configFile := filepath.Join(tmpDir, "gordon.toml")
1069 err := os.WriteFile(configFile, []byte("[routes]\n\"http://insecure.example.com\" = \"myapp:latest\"\n"), 0600)
1070 require.NoError(t, err)
1071
1072 v := viper.New()
1073 v.SetConfigFile(configFile)
1074 v.Set("routes", map[string]interface{}{
1075 "http://insecure.example.com": "myapp:latest",
1076 })
1077
1078 eventBus := mocks.NewMockEventPublisher(t)
1079 svc := NewService(v, eventBus)
1080 ctx := testContext()
1081
1082 _ = svc.Load(ctx)
1083
1084 // Caller passes plain domain (not the storage key)
1085 err = svc.RemoveRoute(ctx, "insecure.example.com")
1086 assert.NoError(t, err, "RemoveRoute should find insecure route via http:// fallback")
1087
1088 config := svc.GetConfig()
1089 _, exists := config.Routes["insecure.example.com"]
1090 assert.False(t, exists, "insecure route should be removed")
1091}
1092
1093func TestService_RemoveRoute_ReconcilesCoexistingLegacyKey(t *testing.T) {
1094 svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "myapp:latest", "myapp:old")
1095
1096 err := svc.RemoveRoute(ctx, "app.example.com")
1097 assert.NoError(t, err)
1098
1099 config := svc.GetConfig()
1100 _, exists := config.Routes["app.example.com"]
1101 assert.False(t, exists, "canonical route should be removed")
1102 _, exists = config.Routes["http://app.example.com"]
1103 assert.False(t, exists, "legacy http route key should be removed during reconciliation")
1104}
1105
1106func TestService_Save_WrapsInvalidRouteDomainError(t *testing.T) {
1107 t.Run("canonical key", func(t *testing.T) {
1108 tmpDir := t.TempDir()
1109 configFile := filepath.Join(tmpDir, "gordon.toml")
1110 err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
1111 require.NoError(t, err)
1112
1113 v := viper.New()
1114 v.SetConfigFile(configFile)
1115 require.NoError(t, v.ReadInConfig())
1116
1117 eventBus := mocks.NewMockEventPublisher(t)
1118 svc := NewService(v, eventBus)
1119 ctx := testContext()
1120 require.NoError(t, svc.Load(ctx))
1121
1122 svc.mu.Lock()
1123 svc.config.Routes["localhost"] = routeConfig{Image: "myapp:latest", HTTPS: true}
1124 svc.mu.Unlock()
1125
1126 err = svc.Save(ctx)
1127 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
1128 })
1129
1130 t.Run("legacy key", func(t *testing.T) {
1131 tmpDir := t.TempDir()
1132 configFile := filepath.Join(tmpDir, "gordon.toml")
1133 err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
1134 require.NoError(t, err)
1135
1136 v := viper.New()
1137 v.SetConfigFile(configFile)
1138 require.NoError(t, v.ReadInConfig())
1139
1140 eventBus := mocks.NewMockEventPublisher(t)
1141 svc := NewService(v, eventBus)
1142 ctx := testContext()
1143 require.NoError(t, svc.Load(ctx))
1144
1145 svc.mu.Lock()
1146 svc.config.Routes["http://localhost"] = routeConfig{Image: "myapp:latest", HTTPS: false}
1147 svc.mu.Unlock()
1148
1149 err = svc.Save(ctx)
1150 assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
1151 })
1152}
1153
1154func TestService_GetNetworkGroups(t *testing.T) {
1155 v := viper.New()
1156 v.Set("network_groups", map[string]interface{}{
1157 "frontend": []interface{}{"app1.example.com", "app2.example.com"},
1158 "backend": []interface{}{"api.example.com", "db.example.com"},
1159 })
1160
1161 eventBus := mocks.NewMockEventPublisher(t)
1162 svc := NewService(v, eventBus)
1163 ctx := testContext()
1164
1165 _ = svc.Load(ctx)
1166
1167 groups := svc.GetNetworkGroups()
1168
1169 assert.Len(t, groups, 2)
1170 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
1171 assert.ElementsMatch(t, []string{"api.example.com", "db.example.com"}, groups["backend"])
1172}
1173
1174func TestService_GetAttachmentConfig(t *testing.T) {
1175 v := viper.New()
1176 v.Set("attachments", map[string]interface{}{
1177 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
1178 })
1179 v.Set("network_groups", map[string]interface{}{
1180 "shared": []interface{}{"app.example.com", "api.example.com"},
1181 })
1182
1183 eventBus := mocks.NewMockEventPublisher(t)
1184 svc := NewService(v, eventBus)
1185 ctx := testContext()
1186
1187 _ = svc.Load(ctx)
1188
1189 snapshot := svc.GetAttachmentConfig()
1190
1191 assert.Len(t, snapshot.Attachments, 1)
1192 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, snapshot.Attachments["app.example.com"])
1193 assert.Len(t, snapshot.NetworkGroups, 1)
1194 assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, snapshot.NetworkGroups["shared"])
1195
1196 snapshot.Attachments["app.example.com"][0] = "mutated"
1197 snapshot.NetworkGroups["shared"][0] = "mutated.example.com"
1198
1199 freshSnapshot := svc.GetAttachmentConfig()
1200 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, freshSnapshot.Attachments["app.example.com"])
1201 assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, freshSnapshot.NetworkGroups["shared"])
1202}
1203
1204func TestService_GetAllAttachments(t *testing.T) {
1205 t.Run("returns all attachments", func(t *testing.T) {
1206 v := viper.New()
1207 v.Set("attachments", map[string]interface{}{
1208 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
1209 "api.example.com": []interface{}{"rabbitmq:3"},
1210 })
1211
1212 eventBus := mocks.NewMockEventPublisher(t)
1213 svc := NewService(v, eventBus)
1214 ctx := testContext()
1215
1216 _ = svc.Load(ctx)
1217
1218 attachments := svc.GetAllAttachments(ctx)
1219
1220 assert.Len(t, attachments, 2)
1221 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
1222 assert.ElementsMatch(t, []string{"rabbitmq:3"}, attachments["api.example.com"])
1223 })
1224
1225 t.Run("returns empty map when no attachments", func(t *testing.T) {
1226 v := viper.New()
1227 eventBus := mocks.NewMockEventPublisher(t)
1228 svc := NewService(v, eventBus)
1229 ctx := testContext()
1230
1231 _ = svc.Load(ctx)
1232
1233 attachments := svc.GetAllAttachments(ctx)
1234
1235 assert.Empty(t, attachments)
1236 })
1237
1238 t.Run("returns copy not reference", func(t *testing.T) {
1239 v := viper.New()
1240 v.Set("attachments", map[string]interface{}{
1241 "app.example.com": []interface{}{"redis:latest"},
1242 })
1243
1244 eventBus := mocks.NewMockEventPublisher(t)
1245 svc := NewService(v, eventBus)
1246 ctx := testContext()
1247
1248 _ = svc.Load(ctx)
1249
1250 attachments := svc.GetAllAttachments(ctx)
1251 // Modify the returned map
1252 attachments["app.example.com"] = append(attachments["app.example.com"], "postgres:18")
1253
1254 // Original should be unchanged
1255 original := svc.GetAllAttachments(ctx)
1256 assert.Len(t, original["app.example.com"], 1)
1257 })
1258}
1259
1260func TestService_GetAttachmentsFor(t *testing.T) {
1261 t.Run("existing domain", func(t *testing.T) {
1262 v := viper.New()
1263 v.Set("attachments", map[string]interface{}{
1264 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
1265 })
1266
1267 eventBus := mocks.NewMockEventPublisher(t)
1268 svc := NewService(v, eventBus)
1269 ctx := testContext()
1270
1271 _ = svc.Load(ctx)
1272
1273 images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
1274
1275 require.NoError(t, err)
1276 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, images)
1277 })
1278
1279 t.Run("existing network group", func(t *testing.T) {
1280 v := viper.New()
1281 v.Set("attachments", map[string]interface{}{
1282 "backend": []interface{}{"rabbitmq:3", "redis:latest"},
1283 })
1284
1285 eventBus := mocks.NewMockEventPublisher(t)
1286 svc := NewService(v, eventBus)
1287 ctx := testContext()
1288
1289 _ = svc.Load(ctx)
1290
1291 images, err := svc.GetAttachmentsFor(ctx, "backend")
1292
1293 require.NoError(t, err)
1294 assert.ElementsMatch(t, []string{"rabbitmq:3", "redis:latest"}, images)
1295 })
1296
1297 t.Run("non-existent target", func(t *testing.T) {
1298 v := viper.New()
1299 eventBus := mocks.NewMockEventPublisher(t)
1300 svc := NewService(v, eventBus)
1301 ctx := testContext()
1302
1303 _ = svc.Load(ctx)
1304
1305 images, err := svc.GetAttachmentsFor(ctx, "notfound.example.com")
1306
1307 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
1308 assert.Nil(t, images)
1309 })
1310
1311 t.Run("returns copy not reference", func(t *testing.T) {
1312 v := viper.New()
1313 v.Set("attachments", map[string]interface{}{
1314 "app.example.com": []interface{}{"redis:latest"},
1315 })
1316
1317 eventBus := mocks.NewMockEventPublisher(t)
1318 svc := NewService(v, eventBus)
1319 ctx := testContext()
1320
1321 _ = svc.Load(ctx)
1322
1323 images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
1324 require.NoError(t, err)
1325
1326 // Modify the returned slice (use _ to satisfy linter)
1327 _ = append(images, "postgres:18")
1328
1329 // Original should be unchanged
1330 original, _ := svc.GetAttachmentsFor(ctx, "app.example.com")
1331 assert.Len(t, original, 1)
1332 })
1333}
1334
1335func TestService_AddAttachment(t *testing.T) {
1336 t.Run("success - new target", func(t *testing.T) {
1337 tmpDir := t.TempDir()
1338 configFile := filepath.Join(tmpDir, "gordon.toml")
1339 err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
1340 require.NoError(t, err)
1341
1342 v := viper.New()
1343 v.SetConfigFile(configFile)
1344 eventBus := mocks.NewMockEventPublisher(t)
1345 svc := NewService(v, eventBus)
1346 ctx := testContext()
1347
1348 _ = svc.Load(ctx)
1349
1350 err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
1351
1352 assert.NoError(t, err)
1353
1354 // Verify attachment was added
1355 config := svc.GetConfig()
1356 assert.Contains(t, config.Attachments["app.example.com"], "postgres:18")
1357 })
1358
1359 t.Run("success - existing target", func(t *testing.T) {
1360 tmpDir := t.TempDir()
1361 configFile := filepath.Join(tmpDir, "gordon.toml")
1362 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
1363 require.NoError(t, err)
1364
1365 v := viper.New()
1366 v.SetConfigFile(configFile)
1367 v.Set("attachments", map[string]interface{}{
1368 "app.example.com": []interface{}{"redis:latest"},
1369 })
1370 eventBus := mocks.NewMockEventPublisher(t)
1371 svc := NewService(v, eventBus)
1372 ctx := testContext()
1373
1374 _ = svc.Load(ctx)
1375
1376 err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
1377
1378 assert.NoError(t, err)
1379
1380 // Verify attachment was added alongside existing
1381 config := svc.GetConfig()
1382 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
1383 })
1384
1385 t.Run("duplicate attachment", func(t *testing.T) {
1386 v := viper.New()
1387 v.Set("attachments", map[string]interface{}{
1388 "app.example.com": []interface{}{"redis:latest"},
1389 })
1390 eventBus := mocks.NewMockEventPublisher(t)
1391 svc := NewService(v, eventBus)
1392 ctx := testContext()
1393
1394 _ = svc.Load(ctx)
1395
1396 err := svc.AddAttachment(ctx, "app.example.com", "redis:latest")
1397
1398 assert.ErrorIs(t, err, domain.ErrAttachmentExists)
1399 })
1400
1401 t.Run("empty target", func(t *testing.T) {
1402 v := viper.New()
1403 eventBus := mocks.NewMockEventPublisher(t)
1404 svc := NewService(v, eventBus)
1405 ctx := testContext()
1406
1407 err := svc.AddAttachment(ctx, "", "postgres:18")
1408
1409 assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
1410 })
1411
1412 t.Run("empty image", func(t *testing.T) {
1413 v := viper.New()
1414 eventBus := mocks.NewMockEventPublisher(t)
1415 svc := NewService(v, eventBus)
1416 ctx := testContext()
1417
1418 err := svc.AddAttachment(ctx, "app.example.com", "")
1419
1420 assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
1421 })
1422
1423 t.Run("network group target", func(t *testing.T) {
1424 tmpDir := t.TempDir()
1425 configFile := filepath.Join(tmpDir, "gordon.toml")
1426 err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
1427 require.NoError(t, err)
1428
1429 v := viper.New()
1430 v.SetConfigFile(configFile)
1431 eventBus := mocks.NewMockEventPublisher(t)
1432 svc := NewService(v, eventBus)
1433 ctx := testContext()
1434
1435 _ = svc.Load(ctx)
1436
1437 err = svc.AddAttachment(ctx, "backend", "rabbitmq:3")
1438
1439 assert.NoError(t, err)
1440
1441 config := svc.GetConfig()
1442 assert.Contains(t, config.Attachments["backend"], "rabbitmq:3")
1443 })
1444}
1445
1446func TestService_RemoveAttachment(t *testing.T) {
1447 t.Run("success - single attachment", func(t *testing.T) {
1448 tmpDir := t.TempDir()
1449 configFile := filepath.Join(tmpDir, "gordon.toml")
1450 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
1451 require.NoError(t, err)
1452
1453 v := viper.New()
1454 v.SetConfigFile(configFile)
1455 v.Set("attachments", map[string]interface{}{
1456 "app.example.com": []interface{}{"redis:latest"},
1457 })
1458 eventBus := mocks.NewMockEventPublisher(t)
1459 svc := NewService(v, eventBus)
1460 ctx := testContext()
1461
1462 _ = svc.Load(ctx)
1463
1464 err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
1465
1466 assert.NoError(t, err)
1467
1468 // Verify target was removed (no attachments left)
1469 config := svc.GetConfig()
1470 _, exists := config.Attachments["app.example.com"]
1471 assert.False(t, exists)
1472 })
1473
1474 t.Run("success - multiple attachments", func(t *testing.T) {
1475 tmpDir := t.TempDir()
1476 configFile := filepath.Join(tmpDir, "gordon.toml")
1477 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\", \"postgres:18\"]\n"), 0600)
1478 require.NoError(t, err)
1479
1480 v := viper.New()
1481 v.SetConfigFile(configFile)
1482 v.Set("attachments", map[string]interface{}{
1483 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
1484 })
1485 eventBus := mocks.NewMockEventPublisher(t)
1486 svc := NewService(v, eventBus)
1487 ctx := testContext()
1488
1489 _ = svc.Load(ctx)
1490
1491 err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
1492
1493 assert.NoError(t, err)
1494
1495 // Verify only redis was removed
1496 config := svc.GetConfig()
1497 assert.Equal(t, []string{"postgres:18"}, config.Attachments["app.example.com"])
1498 })
1499
1500 t.Run("target not found", func(t *testing.T) {
1501 v := viper.New()
1502 eventBus := mocks.NewMockEventPublisher(t)
1503 svc := NewService(v, eventBus)
1504 ctx := testContext()
1505
1506 _ = svc.Load(ctx)
1507
1508 err := svc.RemoveAttachment(ctx, "notfound.example.com", "redis:latest")
1509
1510 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
1511 })
1512
1513 t.Run("image not found in target", func(t *testing.T) {
1514 v := viper.New()
1515 v.Set("attachments", map[string]interface{}{
1516 "app.example.com": []interface{}{"redis:latest"},
1517 })
1518 eventBus := mocks.NewMockEventPublisher(t)
1519 svc := NewService(v, eventBus)
1520 ctx := testContext()
1521
1522 _ = svc.Load(ctx)
1523
1524 err := svc.RemoveAttachment(ctx, "app.example.com", "postgres:18")
1525
1526 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
1527 })
1528
1529 t.Run("empty target", func(t *testing.T) {
1530 v := viper.New()
1531 eventBus := mocks.NewMockEventPublisher(t)
1532 svc := NewService(v, eventBus)
1533 ctx := testContext()
1534
1535 err := svc.RemoveAttachment(ctx, "", "redis:latest")
1536
1537 assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
1538 })
1539
1540 t.Run("empty image", func(t *testing.T) {
1541 v := viper.New()
1542 eventBus := mocks.NewMockEventPublisher(t)
1543 svc := NewService(v, eventBus)
1544 ctx := testContext()
1545
1546 err := svc.RemoveAttachment(ctx, "app.example.com", "")
1547
1548 assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
1549 })
1550
1551 t.Run("network group target", func(t *testing.T) {
1552 tmpDir := t.TempDir()
1553 configFile := filepath.Join(tmpDir, "gordon.toml")
1554 err := os.WriteFile(configFile, []byte("[attachments]\n\"backend\" = [\"rabbitmq:3\"]\n"), 0600)
1555 require.NoError(t, err)
1556
1557 v := viper.New()
1558 v.SetConfigFile(configFile)
1559 v.Set("attachments", map[string]interface{}{
1560 "backend": []interface{}{"rabbitmq:3"},
1561 })
1562 eventBus := mocks.NewMockEventPublisher(t)
1563 svc := NewService(v, eventBus)
1564 ctx := testContext()
1565
1566 _ = svc.Load(ctx)
1567
1568 err = svc.RemoveAttachment(ctx, "backend", "rabbitmq:3")
1569
1570 assert.NoError(t, err)
1571
1572 config := svc.GetConfig()
1573 _, exists := config.Attachments["backend"]
1574 assert.False(t, exists)
1575 })
1576}
1577
1578func TestService_AutoRouteAllowedDomains(t *testing.T) {
1579 t.Run("get returns copy", func(t *testing.T) {
1580 v := viper.New()
1581 v.Set("auto_route_allowed_domains", []string{"example.com", "*.example.com"})
1582
1583 eventBus := mocks.NewMockEventPublisher(t)
1584 svc := NewService(v, eventBus)
1585 ctx := testContext()
1586
1587 require.NoError(t, svc.Load(ctx))
1588
1589 domains, err := svc.GetAutoRouteAllowedDomains(ctx)
1590 require.NoError(t, err)
1591 domains[0] = "changed.com"
1592
1593 again, err := svc.GetAutoRouteAllowedDomains(ctx)
1594 require.NoError(t, err)
1595 assert.Equal(t, []string{"example.com", "*.example.com"}, again)
1596 })
1597
1598 t.Run("add and remove persist idempotently", func(t *testing.T) {
1599 tmpDir := t.TempDir()
1600 configFile := filepath.Join(tmpDir, "gordon.toml")
1601 require.NoError(t, os.WriteFile(configFile, []byte("auto_route_allowed_domains = [\"example.com\"]\n"), 0600))
1602
1603 v := viper.New()
1604 v.SetConfigFile(configFile)
1605 v.Set("auto_route_allowed_domains", []interface{}{"example.com"})
1606
1607 eventBus := mocks.NewMockEventPublisher(t)
1608 svc := NewService(v, eventBus)
1609 ctx := testContext()
1610
1611 require.NoError(t, svc.Load(ctx))
1612 require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
1613 require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
1614
1615 domains, err := svc.GetAutoRouteAllowedDomains(ctx)
1616 require.NoError(t, err)
1617 assert.Equal(t, []string{"example.com", "*.example.com"}, domains)
1618
1619 require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "example.com"))
1620 require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "missing.com"))
1621
1622 domains, err = svc.GetAutoRouteAllowedDomains(ctx)
1623 require.NoError(t, err)
1624 assert.Equal(t, []string{"*.example.com"}, domains)
1625 })
1626
1627 t.Run("add validates patterns", func(t *testing.T) {
1628 v := viper.New()
1629 eventBus := mocks.NewMockEventPublisher(t)
1630 svc := NewService(v, eventBus)
1631 ctx := testContext()
1632 require.NoError(t, svc.Load(ctx))
1633
1634 invalid := []string{"", "EXAMPLE.com", "example.com.", "**.example.com", "foo.*.example.com", "*.example.com."}
1635 for _, pattern := range invalid {
1636 err := svc.AddAutoRouteAllowedDomain(ctx, pattern)
1637 assert.ErrorIs(t, err, domain.ErrInvalidDomainPattern, "pattern: %s", pattern)
1638 }
1639 })
1640}
1641
1642func TestService_GetExternalRoutes(t *testing.T) {
1643 v := viper.New()
1644 v.Set("external_routes", map[string]interface{}{
1645 "Reg.Example.com": "localhost:5000",
1646 "cache.example.com": "127.0.0.1:6379",
1647 })
1648
1649 eventBus := mocks.NewMockEventPublisher(t)
1650 svc := NewService(v, eventBus)
1651 ctx := testContext()
1652
1653 _ = svc.Load(ctx)
1654
1655 routes := svc.GetExternalRoutes()
1656
1657 assert.Len(t, routes, 2)
1658 assert.Equal(t, "localhost:5000", routes["reg.example.com"])
1659 assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
1660}
1661
1662func TestService_GetExternalRoutes_Empty(t *testing.T) {
1663 v := viper.New()
1664
1665 eventBus := mocks.NewMockEventPublisher(t)
1666 svc := NewService(v, eventBus)
1667 ctx := testContext()
1668
1669 _ = svc.Load(ctx)
1670
1671 routes := svc.GetExternalRoutes()
1672
1673 assert.Empty(t, routes)
1674}
1675
1676func TestSplitImageNameTag(t *testing.T) {
1677 tests := []struct {
1678 name string
1679 image string
1680 wantName string
1681 wantHasTag bool
1682 }{
1683 {"with tag", "myapp:latest", "myapp", true},
1684 {"with version tag", "myapp:v1.2.0", "myapp", true},
1685 {"no tag", "myapp", "myapp", false},
1686 {"empty string", "", "", false},
1687 {"tag only colon", "myapp:", "myapp", true},
1688 {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true},
1689 {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false},
1690 }
1691
1692 for _, tt := range tests {
1693 t.Run(tt.name, func(t *testing.T) {
1694 name, hasTag := splitImageNameTag(tt.image)
1695 assert.Equal(t, tt.wantName, name)
1696 assert.Equal(t, tt.wantHasTag, hasTag)
1697 })
1698 }
1699}
1700
1701func TestNormalizeRegistryImage(t *testing.T) {
1702 tests := []struct {
1703 name string
1704 imageName string
1705 registryDomain string
1706 expected string
1707 }{
1708 {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
1709 {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
1710 {"empty registry domain", "myapp:latest", "", "myapp:latest"},
1711 {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
1712 {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
1713 {"bare name no registry", "myapp", "reg.example.com", "myapp"},
1714 {"exact registry without image", "reg.example.com/", "reg.example.com", ""},
1715 }
1716
1717 for _, tt := range tests {
1718 t.Run(tt.name, func(t *testing.T) {
1719 result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
1720 assert.Equal(t, tt.expected, result)
1721 })
1722 }
1723}
1724
1725func TestNormalizeBootstrapImage(t *testing.T) {
1726 tests := []struct {
1727 name string
1728 image string
1729 registryDomain string
1730 expected string
1731 wantErr string
1732 }{
1733 {name: "bare name prepends registry", image: "myapp", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1734 {name: "bare name strips tag", image: "myapp:v1", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1735 {name: "qualified image unchanged", image: "reg.example.com/myapp", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1736 {name: "qualified image strips tag", image: "reg.example.com/myapp:abc123", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1737 {name: "other registry strips tag", image: "other.registry.com/myapp:v2", registryDomain: "reg.example.com", expected: "other.registry.com/myapp"},
1738 {name: "empty image", image: "", registryDomain: "reg.example.com", wantErr: "image is required"},
1739 {name: "empty registry domain", image: "pitlane", registryDomain: "", wantErr: "registry domain is not configured"},
1740 }
1741
1742 for _, tt := range tests {
1743 t.Run(tt.name, func(t *testing.T) {
1744 result, err := NormalizeBootstrapImage(tt.image, tt.registryDomain)
1745 if tt.wantErr != "" {
1746 require.Error(t, err)
1747 assert.Equal(t, tt.wantErr, err.Error())
1748 assert.Empty(t, result)
1749 return
1750 }
1751
1752 require.NoError(t, err)
1753 assert.Equal(t, tt.expected, result)
1754 })
1755 }
1756}
1757
1758func TestService_FindRoutesByImage(t *testing.T) {
1759 t.Run("exact match with tag", func(t *testing.T) {
1760 v := viper.New()
1761 v.Set("routes", map[string]interface{}{
1762 "app.example.com": "myapp:latest",
1763 })
1764
1765 eventBus := mocks.NewMockEventPublisher(t)
1766 svc := NewService(v, eventBus)
1767 ctx := testContext()
1768 _ = svc.Load(ctx)
1769
1770 routes := svc.FindRoutesByImage(ctx, "myapp:latest")
1771 assert.Len(t, routes, 1)
1772 assert.Equal(t, "app.example.com", routes[0].Domain)
1773 assert.Equal(t, "myapp:latest", routes[0].Image)
1774 })
1775
1776 t.Run("bare name matches route with tag", func(t *testing.T) {
1777 v := viper.New()
1778 v.Set("routes", map[string]interface{}{
1779 "app.example.com": "myapp:latest",
1780 })
1781
1782 eventBus := mocks.NewMockEventPublisher(t)
1783 svc := NewService(v, eventBus)
1784 ctx := testContext()
1785 _ = svc.Load(ctx)
1786
1787 routes := svc.FindRoutesByImage(ctx, "myapp")
1788 assert.Len(t, routes, 1)
1789 assert.Equal(t, "app.example.com", routes[0].Domain)
1790 })
1791
1792 t.Run("bare name matches route with version tag", func(t *testing.T) {
1793 v := viper.New()
1794 v.Set("routes", map[string]interface{}{
1795 "app.example.com": "myapp:v2.0.0",
1796 })
1797
1798 eventBus := mocks.NewMockEventPublisher(t)
1799 svc := NewService(v, eventBus)
1800 ctx := testContext()
1801 _ = svc.Load(ctx)
1802
1803 routes := svc.FindRoutesByImage(ctx, "myapp")
1804 assert.Len(t, routes, 1)
1805 assert.Equal(t, "app.example.com", routes[0].Domain)
1806 })
1807
1808 t.Run("tag mismatch does not match", func(t *testing.T) {
1809 v := viper.New()
1810 v.Set("routes", map[string]interface{}{
1811 "app.example.com": "myapp:v1",
1812 })
1813
1814 eventBus := mocks.NewMockEventPublisher(t)
1815 svc := NewService(v, eventBus)
1816 ctx := testContext()
1817 _ = svc.Load(ctx)
1818
1819 routes := svc.FindRoutesByImage(ctx, "myapp:v2")
1820 assert.Empty(t, routes)
1821 })
1822
1823 t.Run("no match", func(t *testing.T) {
1824 v := viper.New()
1825 v.Set("routes", map[string]interface{}{
1826 "app.example.com": "myapp:latest",
1827 })
1828
1829 eventBus := mocks.NewMockEventPublisher(t)
1830 svc := NewService(v, eventBus)
1831 ctx := testContext()
1832 _ = svc.Load(ctx)
1833
1834 routes := svc.FindRoutesByImage(ctx, "otherapp")
1835 assert.Empty(t, routes)
1836 })
1837
1838 t.Run("strips registry prefix before matching", func(t *testing.T) {
1839 v := viper.New()
1840 v.Set("server.registry_domain", "reg.example.com")
1841 v.Set("routes", map[string]interface{}{
1842 "app.example.com": "reg.example.com/myapp:latest",
1843 })
1844
1845 eventBus := mocks.NewMockEventPublisher(t)
1846 svc := NewService(v, eventBus)
1847 ctx := testContext()
1848 _ = svc.Load(ctx)
1849
1850 routes := svc.FindRoutesByImage(ctx, "myapp")
1851 assert.Len(t, routes, 1)
1852 assert.Equal(t, "app.example.com", routes[0].Domain)
1853 })
1854
1855 t.Run("strips registry prefix from input too", func(t *testing.T) {
1856 v := viper.New()
1857 v.Set("server.registry_domain", "reg.example.com")
1858 v.Set("routes", map[string]interface{}{
1859 "app.example.com": "reg.example.com/myapp:latest",
1860 })
1861
1862 eventBus := mocks.NewMockEventPublisher(t)
1863 svc := NewService(v, eventBus)
1864 ctx := testContext()
1865 _ = svc.Load(ctx)
1866
1867 routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest")
1868 assert.Len(t, routes, 1)
1869 assert.Equal(t, "app.example.com", routes[0].Domain)
1870 })
1871
1872 t.Run("multiple routes for same image", func(t *testing.T) {
1873 v := viper.New()
1874 v.Set("routes", map[string]interface{}{
1875 "app1.example.com": "myapp:latest",
1876 "app2.example.com": "myapp:latest",
1877 "other.example.com": "otherapp:latest",
1878 })
1879
1880 eventBus := mocks.NewMockEventPublisher(t)
1881 svc := NewService(v, eventBus)
1882 ctx := testContext()
1883 _ = svc.Load(ctx)
1884
1885 routes := svc.FindRoutesByImage(ctx, "myapp")
1886 assert.Len(t, routes, 2)
1887
1888 domains := []string{routes[0].Domain, routes[1].Domain}
1889 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains)
1890 })
1891
1892 t.Run("case insensitive match", func(t *testing.T) {
1893 v := viper.New()
1894 v.Set("routes", map[string]interface{}{
1895 "app.example.com": "MyApp:latest",
1896 })
1897
1898 eventBus := mocks.NewMockEventPublisher(t)
1899 svc := NewService(v, eventBus)
1900 ctx := testContext()
1901 _ = svc.Load(ctx)
1902
1903 routes := svc.FindRoutesByImage(ctx, "myapp")
1904 assert.Len(t, routes, 1)
1905 })
1906
1907 t.Run("http prefix route sets HTTPS false", func(t *testing.T) {
1908 v := viper.New()
1909 v.Set("routes", map[string]interface{}{
1910 "http://insecure.example.com": "myapp:latest",
1911 })
1912
1913 eventBus := mocks.NewMockEventPublisher(t)
1914 svc := NewService(v, eventBus)
1915 ctx := testContext()
1916 _ = svc.Load(ctx)
1917
1918 routes := svc.FindRoutesByImage(ctx, "myapp")
1919 assert.Len(t, routes, 1)
1920 assert.Equal(t, "insecure.example.com", routes[0].Domain)
1921 assert.False(t, routes[0].HTTPS)
1922 })
1923
1924 t.Run("empty routes", func(t *testing.T) {
1925 v := viper.New()
1926 eventBus := mocks.NewMockEventPublisher(t)
1927 svc := NewService(v, eventBus)
1928 ctx := testContext()
1929 _ = svc.Load(ctx)
1930
1931 routes := svc.FindRoutesByImage(ctx, "myapp")
1932 assert.Empty(t, routes)
1933 })
1934}
1935
1936func TestService_FindAttachmentTargetsByImage(t *testing.T) {
1937 t.Run("exact tagged matches", func(t *testing.T) {
1938 v := viper.New()
1939 v.Set("attachments", map[string]interface{}{
1940 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
1941 })
1942
1943 eventBus := mocks.NewMockEventPublisher(t)
1944 svc := NewService(v, eventBus)
1945 ctx := testContext()
1946 _ = svc.Load(ctx)
1947
1948 targets := svc.FindAttachmentTargetsByImage(ctx, "postgres:18")
1949 assert.Equal(t, []string{"app.example.com"}, targets)
1950 })
1951
1952 t.Run("bare name matches tagged attachments", func(t *testing.T) {
1953 v := viper.New()
1954 v.Set("attachments", map[string]interface{}{
1955 "app.example.com": []interface{}{"postgres:18"},
1956 })
1957
1958 eventBus := mocks.NewMockEventPublisher(t)
1959 svc := NewService(v, eventBus)
1960 ctx := testContext()
1961 _ = svc.Load(ctx)
1962
1963 targets := svc.FindAttachmentTargetsByImage(ctx, "postgres")
1964 assert.Equal(t, []string{"app.example.com"}, targets)
1965 })
1966
1967 t.Run("registry qualified input is normalized", func(t *testing.T) {
1968 v := viper.New()
1969 v.Set("server.registry_domain", "reg.example.com")
1970 v.Set("attachments", map[string]interface{}{
1971 "backend": []interface{}{"reg.example.com/postgres:18"},
1972 })
1973
1974 eventBus := mocks.NewMockEventPublisher(t)
1975 svc := NewService(v, eventBus)
1976 ctx := testContext()
1977 _ = svc.Load(ctx)
1978
1979 targets := svc.FindAttachmentTargetsByImage(ctx, "reg.example.com/postgres:18")
1980 assert.Equal(t, []string{"backend"}, targets)
1981 })
1982
1983 t.Run("shared attachment used by multiple targets", func(t *testing.T) {
1984 v := viper.New()
1985 v.Set("attachments", map[string]interface{}{
1986 "app.example.com": []interface{}{"redis:latest"},
1987 "backend": []interface{}{"redis:latest", "postgres:18"},
1988 "worker": []interface{}{"rabbitmq:3"},
1989 })
1990
1991 eventBus := mocks.NewMockEventPublisher(t)
1992 svc := NewService(v, eventBus)
1993 ctx := testContext()
1994 _ = svc.Load(ctx)
1995
1996 targets := svc.FindAttachmentTargetsByImage(ctx, "redis")
1997 assert.ElementsMatch(t, []string{"app.example.com", "backend"}, targets)
1998 })
1999
2000 t.Run("no matches", func(t *testing.T) {
2001 v := viper.New()
2002 v.Set("attachments", map[string]interface{}{
2003 "app.example.com": []interface{}{"redis:latest"},
2004 })
2005
2006 eventBus := mocks.NewMockEventPublisher(t)
2007 svc := NewService(v, eventBus)
2008 ctx := testContext()
2009 _ = svc.Load(ctx)
2010
2011 targets := svc.FindAttachmentTargetsByImage(ctx, "postgres")
2012 assert.Empty(t, targets)
2013 })
2014}
2015
2016func TestSavePreservesAllConfigFields(t *testing.T) {
2017 t.Run("does not write predictable tmp path", func(t *testing.T) {
2018 dir := t.TempDir()
2019 configFile := filepath.Join(dir, "gordon.toml")
2020 err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n\n[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
2021 require.NoError(t, err)
2022
2023 v := viper.New()
2024 v.SetConfigFile(configFile)
2025 err = v.ReadInConfig()
2026 require.NoError(t, err)
2027
2028 eventBus := mocks.NewMockEventPublisher(t)
2029 svc := NewService(v, eventBus)
2030 ctx := testContext()
2031 err = svc.Load(ctx)
2032 require.NoError(t, err)
2033
2034 maliciousTarget := filepath.Join(dir, "pwned.txt")
2035 predictableTmp := configFile + ".tmp"
2036 err = os.Symlink(maliciousTarget, predictableTmp)
2037 require.NoError(t, err)
2038
2039 err = svc.Save(ctx)
2040 require.NoError(t, err)
2041
2042 _, statErr := os.Stat(maliciousTarget)
2043 assert.True(t, os.IsNotExist(statErr), "symlink target should not exist - temp file should use unpredictable name")
2044
2045 info, err := os.Lstat(predictableTmp)
2046 require.NoError(t, err)
2047 assert.NotZero(t, info.Mode()&os.ModeSymlink, "predictable .tmp path should still be the original symlink")
2048 })
2049
2050 t.Run("with defaults does not corrupt config", func(t *testing.T) {
2051 tmpDir := t.TempDir()
2052 configFile := filepath.Join(tmpDir, "gordon.toml")
2053 initialConfig := `
2054[server]
2055port = 8088
2056registry_port = 5000
2057registry_domain = "reg.example.com"
2058tls_enabled = true
2059tls_port = 8443
2060data_dir = "/tmp/test-gordon"
2061runtime = "auto"
2062
2063[auth]
2064enabled = true
2065secrets_backend = "pass"
2066username = "testadmin"
2067password_hash = "test/auth/password_hash"
2068token_secret = "test/auth/token_secret"
2069token_expiry = "720h"
2070type = "password"
2071
2072[auto_route]
2073enabled = true
2074
2075[network_isolation]
2076enabled = true
2077network_prefix = "gordon"
2078dns_suffix = ".internal"
2079
2080[backups]
2081enabled = true
2082schedule = "daily"
2083storage_dir = "~/.gordon/backups"
2084
2085[routes]
2086"app.example.com" = "reg.example.com/myapp:latest"
2087
2088[attachments]
2089"app.example.com" = ["postgres:15"]
2090
2091[network_groups]
2092backend = ["app.example.com"]
2093`
2094 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
2095 require.NoError(t, err)
2096
2097 v := viper.New()
2098 v.SetConfigFile(configFile)
2099 v.SetDefault("server.port", 8088)
2100 v.SetDefault("server.tls_port", 8443)
2101 v.SetDefault("auth.secrets_backend", "")
2102 v.SetDefault("auto_route.enabled", false)
2103 v.SetDefault("network_isolation.enabled", false)
2104 v.SetDefault("backups.enabled", false)
2105
2106 err = v.ReadInConfig()
2107 require.NoError(t, err)
2108
2109 eventBus := mocks.NewMockEventPublisher(t)
2110 svc := NewService(v, eventBus)
2111 ctx := testContext()
2112
2113 err = svc.Load(ctx)
2114 require.NoError(t, err)
2115
2116 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
2117 require.NoError(t, err)
2118
2119 v2 := viper.New()
2120 v2.SetConfigFile(configFile)
2121 err = v2.ReadInConfig()
2122 require.NoError(t, err)
2123
2124 assert.Equal(t, 8088, v2.GetInt("server.port"))
2125 assert.Equal(t, 8443, v2.GetInt("server.tls_port"))
2126 assert.Equal(t, "reg.example.com", v2.GetString("server.registry_domain"))
2127 assert.Equal(t, "auto", v2.GetString("server.runtime"))
2128 assert.Equal(t, "pass", v2.GetString("auth.secrets_backend"))
2129 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
2130 assert.Equal(t, "test/auth/password_hash", v2.GetString("auth.password_hash"))
2131 assert.Equal(t, "test/auth/token_secret", v2.GetString("auth.token_secret"))
2132 assert.Equal(t, "password", v2.GetString("auth.type"))
2133 assert.True(t, v2.GetBool("auto_route.enabled"))
2134 assert.True(t, v2.GetBool("network_isolation.enabled"))
2135 assert.Equal(t, ".internal", v2.GetString("network_isolation.dns_suffix"))
2136 assert.True(t, v2.GetBool("backups.enabled"))
2137 assert.Equal(t, "~/.gordon/backups", v2.GetString("backups.storage_dir"))
2138
2139 savedRoutes, err := loadCanonicalRoutes(v2.Get("routes"))
2140 require.NoError(t, err)
2141 routeCfg := requireRoute(t, savedRoutes, "new.example.com")
2142 assert.Equal(t, "newapp:latest", routeCfg.Image)
2143 assert.False(t, routeCfg.HTTPS)
2144 routeCfg = requireRoute(t, savedRoutes, "app.example.com")
2145 assert.Equal(t, "reg.example.com/myapp:latest", routeCfg.Image)
2146 assert.True(t, routeCfg.HTTPS)
2147
2148 matches, err := filepath.Glob(configFile + ".bak.*")
2149 require.NoError(t, err)
2150 assert.NotEmpty(t, matches)
2151 })
2152
2153 t.Run("backup created before write", func(t *testing.T) {
2154 tmpDir := t.TempDir()
2155 configFile := filepath.Join(tmpDir, "gordon.toml")
2156 initialConfig := `
2157[server]
2158port = 9999
2159
2160[routes]
2161"app.example.com" = "myapp:latest"
2162`
2163 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
2164 require.NoError(t, err)
2165
2166 v := viper.New()
2167 v.SetConfigFile(configFile)
2168 err = v.ReadInConfig()
2169 require.NoError(t, err)
2170
2171 eventBus := mocks.NewMockEventPublisher(t)
2172 svc := NewService(v, eventBus)
2173 ctx := testContext()
2174 err = svc.Load(ctx)
2175 require.NoError(t, err)
2176
2177 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
2178 require.NoError(t, err)
2179
2180 matches, err := filepath.Glob(configFile + ".bak.*")
2181 require.NoError(t, err)
2182 require.NotEmpty(t, matches)
2183
2184 backupData, err := os.ReadFile(matches[0])
2185 require.NoError(t, err)
2186 assert.Contains(t, string(backupData), "port = 9999")
2187 assert.NotContains(t, string(backupData), "new.example.com")
2188 })
2189
2190 t.Run("old backups are cleaned up keeping 5", func(t *testing.T) {
2191 tmpDir := t.TempDir()
2192 configFile := filepath.Join(tmpDir, "gordon.toml")
2193 initialConfig := `
2194[server]
2195port = 9999
2196
2197[routes]
2198"app.example.com" = "myapp:latest"
2199`
2200 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
2201 require.NoError(t, err)
2202
2203 for i := 1000000000000; i < 1000000000007; i++ {
2204 backupPath := fmt.Sprintf("%s.bak.%d", configFile, i)
2205 err := os.WriteFile(backupPath, []byte("old backup"), 0600)
2206 require.NoError(t, err)
2207 }
2208
2209 v := viper.New()
2210 v.SetConfigFile(configFile)
2211 err = v.ReadInConfig()
2212 require.NoError(t, err)
2213
2214 eventBus := mocks.NewMockEventPublisher(t)
2215 svc := NewService(v, eventBus)
2216 ctx := testContext()
2217 err = svc.Load(ctx)
2218 require.NoError(t, err)
2219
2220 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
2221 require.NoError(t, err)
2222
2223 matches, err := filepath.Glob(configFile + ".bak.*")
2224 require.NoError(t, err)
2225 assert.Equal(t, 5, len(matches))
2226 })
2227
2228 t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {
2229 tmpDir := t.TempDir()
2230 configFile := filepath.Join(tmpDir, "gordon.toml")
2231 initialConfig := `
2232[server]
2233port = 8088
2234
2235[auth]
2236username = "testadmin"
2237token_secret = "testsecret123"
2238
2239[routes]
2240"pitlane.example.com" = "pitlane:latest"
2241`
2242 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
2243 require.NoError(t, err)
2244
2245 v := viper.New()
2246 v.SetConfigFile(configFile)
2247 err = v.ReadInConfig()
2248 require.NoError(t, err)
2249
2250 eventBus := mocks.NewMockEventPublisher(t)
2251 svc := NewService(v, eventBus)
2252 ctx := testContext()
2253 err = svc.Load(ctx)
2254 require.NoError(t, err)
2255
2256 err = svc.AddRoute(ctx, domain.Route{
2257 Domain: "pitlane.example.com",
2258 Image: "reg.example.com/pitlane:latest",
2259 })
2260 require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route")
2261
2262 routes := svc.GetRoutes(ctx)
2263 require.Len(t, routes, 1)
2264 assert.Equal(t, "pitlane.example.com", routes[0].Domain)
2265 assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image)
2266
2267 v2 := viper.New()
2268 v2.SetConfigFile(configFile)
2269 err = v2.ReadInConfig()
2270 require.NoError(t, err)
2271 assert.Equal(t, 8088, v2.GetInt("server.port"))
2272 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
2273 assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
2274 })
2275
2276 t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) {
2277 tmpDir := t.TempDir()
2278 configFile := filepath.Join(tmpDir, "gordon.toml")
2279 initialConfig := `
2280[server]
2281port = 8088
2282
2283[auth]
2284username = "testadmin"
2285token_secret = "testsecret123"
2286
2287[routes]
2288"pitlane.example.com" = "reg.example.com/pitlane:latest"
2289"other.example.com" = "reg.example.com/other:latest"
2290`
2291 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
2292 require.NoError(t, err)
2293
2294 v := viper.New()
2295 v.SetConfigFile(configFile)
2296 err = v.ReadInConfig()
2297 require.NoError(t, err)
2298
2299 eventBus := mocks.NewMockEventPublisher(t)
2300 svc := NewService(v, eventBus)
2301 ctx := testContext()
2302 err = svc.Load(ctx)
2303 require.NoError(t, err)
2304
2305 err = svc.RemoveRoute(ctx, "pitlane.example.com")
2306 require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route")
2307
2308 routes := svc.GetRoutes(ctx)
2309 require.Len(t, routes, 1)
2310 assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved")
2311 assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved")
2312
2313 v2 := viper.New()
2314 v2.SetConfigFile(configFile)
2315 err = v2.ReadInConfig()
2316 require.NoError(t, err)
2317 assert.Equal(t, 8088, v2.GetInt("server.port"))
2318 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
2319 assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
2320 })
2321 t.Run("network_groups are persisted to disk", func(t *testing.T) {
2322 // Setup: config file with auth, server, and network_groups
2323 tmpDir := t.TempDir()
2324 configFile := filepath.Join(tmpDir, "gordon.toml")
2325 initialConfig := `
2326[server]
2327port = 9999
2328
2329[auth]
2330username = "admin"
2331token_secret = "supersecretvalue"
2332
2333[routes]
2334"app.example.com" = "myapp:latest"
2335
2336[network_groups]
2337frontend = ["app1.example.com", "app2.example.com"]
2338`
2339 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
2340 require.NoError(t, err)
2341
2342 v := viper.New()
2343 v.SetConfigFile(configFile)
2344 err = v.ReadInConfig()
2345 require.NoError(t, err)
2346
2347 eventBus := mocks.NewMockEventPublisher(t)
2348 svc := NewService(v, eventBus)
2349 ctx := testContext()
2350
2351 err = svc.Load(ctx)
2352 require.NoError(t, err)
2353
2354 // Verify network_groups loaded correctly in memory
2355 groups := svc.GetNetworkGroups()
2356 require.Len(t, groups, 1)
2357 require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
2358
2359 // Trigger a Save via AddRoute (which calls Save internally)
2360 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
2361 require.NoError(t, err)
2362
2363 // Re-read the file to verify network_groups was persisted
2364 v2 := viper.New()
2365 v2.SetConfigFile(configFile)
2366 err = v2.ReadInConfig()
2367 require.NoError(t, err)
2368
2369 // Verify network_groups is still intact and correct after save
2370 savedGroups := loadStringArrayMap(v2.Get("network_groups"))
2371 assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config")
2372 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"])
2373
2374 // Verify auth fields are preserved
2375 assert.Equal(t, "admin", v2.GetString("auth.username"))
2376 assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret"))
2377
2378 // Verify server fields are preserved
2379 assert.Equal(t, 9999, v2.GetInt("server.port"))
2380 })
2381
2382 // Regression test: external_routes keys contain dots (e.g. "reg.example.com").
2383 // Without explicitly calling viper.Set("external_routes", ...) before WriteConfig,
2384 // viper splits dotted keys into nested subtrees, corrupting the data on re-read.
2385 t.Run("external_routes with dotted domain keys are not corrupted on Save", func(t *testing.T) {
2386 tmpDir := t.TempDir()
2387 configFile := filepath.Join(tmpDir, "gordon.toml")
2388 initialConfig := `
2389[server]
2390port = 8080
2391
2392[routes]
2393"app.example.com" = "myapp:latest"
2394
2395[external_routes]
2396"reg.example.com" = "localhost:5000"
2397`
2398 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
2399 require.NoError(t, err)
2400
2401 v := viper.New()
2402 v.SetConfigFile(configFile)
2403 err = v.ReadInConfig()
2404 require.NoError(t, err)
2405
2406 eventBus := mocks.NewMockEventPublisher(t)
2407 svc := NewService(v, eventBus)
2408 ctx := testContext()
2409
2410 err = svc.Load(ctx)
2411 require.NoError(t, err)
2412
2413 // Verify external_routes loaded correctly in memory
2414 extRoutes := svc.GetExternalRoutes()
2415 require.Equal(t, "localhost:5000", extRoutes["reg.example.com"])
2416
2417 // Trigger Save
2418 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
2419 require.NoError(t, err)
2420
2421 // Re-read the file with a fresh viper instance
2422 v2 := viper.New()
2423 v2.SetConfigFile(configFile)
2424 err = v2.ReadInConfig()
2425 require.NoError(t, err)
2426
2427 // external_routes must survive Save with correct flat map structure.
2428 // Without the fix, viper splits "reg.example.com" into nested TOML subtrees
2429 // and GetString("external_routes.reg\\.example\\.com") returns empty string.
2430 savedExtRoutes := loadStringMap(v2.Get("external_routes"))
2431 assert.Equal(t, "localhost:5000", savedExtRoutes["reg.example.com"],
2432 "external_routes must not be corrupted by viper's dot-path splitting on WriteConfig")
2433 })
2434
2435 t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) {
2436 // Setup: config file WITHOUT network_groups
2437 tmpDir := t.TempDir()
2438 configFile := filepath.Join(tmpDir, "gordon.toml")
2439 initialConfig := `
2440[server]
2441port = 8080
2442
2443[routes]
2444"app.example.com" = "myapp:latest"
2445`
2446 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
2447 require.NoError(t, err)
2448
2449 v := viper.New()
2450 v.SetConfigFile(configFile)
2451 err = v.ReadInConfig()
2452 require.NoError(t, err)
2453
2454 // Inject network_groups into viper as if they were loaded from some other source
2455 // This simulates a case where network_groups exist in memory but not on disk
2456 v.Set("network_groups", map[string]interface{}{
2457 "backend": []interface{}{"api.example.com"},
2458 })
2459
2460 eventBus := mocks.NewMockEventPublisher(t)
2461 svc := NewService(v, eventBus)
2462 ctx := testContext()
2463
2464 err = svc.Load(ctx)
2465 require.NoError(t, err)
2466
2467 // Verify network_groups is loaded in memory
2468 groups := svc.GetNetworkGroups()
2469 require.Len(t, groups, 1)
2470
2471 // Trigger Save
2472 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
2473 require.NoError(t, err)
2474
2475 // Re-read the file
2476 v2 := viper.New()
2477 v2.SetConfigFile(configFile)
2478 err = v2.ReadInConfig()
2479 require.NoError(t, err)
2480
2481 // network_groups must be present in the saved file
2482 savedGroups := loadStringArrayMap(v2.Get("network_groups"))
2483 assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save")
2484 assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"])
2485 })
2486}
2487
2488func TestExtractDomainFromImageName(t *testing.T) {
2489 tests := []struct {
2490 name string
2491 imageName string
2492 expectedDom string
2493 expectedFound bool
2494 }{
2495 {
2496 name: "domain like image",
2497 imageName: "myapp.example.com:latest",
2498 expectedDom: "myapp.example.com",
2499 expectedFound: true,
2500 },
2501 {
2502 name: "domain like image without tag",
2503 imageName: "api.backend.io",
2504 expectedDom: "api.backend.io",
2505 expectedFound: true,
2506 },
2507 {
2508 name: "simple image name",
2509 imageName: "nginx:latest",
2510 expectedDom: "",
2511 expectedFound: false,
2512 },
2513 {
2514 name: "registry path",
2515 imageName: "gcr.io/project/image:tag",
2516 expectedDom: "gcr.io/project/image",
2517 expectedFound: true,
2518 },
2519 {
2520 name: "leading dot",
2521 imageName: ".invalid:latest",
2522 expectedDom: "",
2523 expectedFound: false,
2524 },
2525 {
2526 name: "trailing dot",
2527 imageName: "invalid.:latest",
2528 expectedDom: "",
2529 expectedFound: false,
2530 },
2531 }
2532
2533 for _, tt := range tests {
2534 t.Run(tt.name, func(t *testing.T) {
2535 domain, found := ExtractDomainFromImageName(tt.imageName)
2536 assert.Equal(t, tt.expectedDom, domain)
2537 assert.Equal(t, tt.expectedFound, found)
2538 })
2539 }
2540}
2541
2542func TestLoadStringMap(t *testing.T) {
2543 tests := []struct {
2544 name string
2545 input any
2546 expected map[string]string
2547 }{
2548 {
2549 name: "nil input",
2550 input: nil,
2551 expected: map[string]string{},
2552 },
2553 {
2554 name: "valid map",
2555 input: map[string]any{
2556 "key1": "value1",
2557 "key2": "value2",
2558 },
2559 expected: map[string]string{
2560 "key1": "value1",
2561 "key2": "value2",
2562 },
2563 },
2564 {
2565 name: "invalid type",
2566 input: "not a map",
2567 expected: map[string]string{},
2568 },
2569 {
2570 name: "mixed values",
2571 input: map[string]any{
2572 "string": "value",
2573 "int": 123,
2574 },
2575 expected: map[string]string{
2576 "string": "value",
2577 },
2578 },
2579 }
2580
2581 for _, tt := range tests {
2582 t.Run(tt.name, func(t *testing.T) {
2583 result := loadStringMap(tt.input)
2584 assert.Equal(t, tt.expected, result)
2585 })
2586 }
2587}
2588
2589func TestLoadStringArrayMap(t *testing.T) {
2590 tests := []struct {
2591 name string
2592 input any
2593 expected map[string][]string
2594 }{
2595 {
2596 name: "nil input",
2597 input: nil,
2598 expected: map[string][]string{},
2599 },
2600 {
2601 name: "valid map",
2602 input: map[string]any{
2603 "group1": []any{"a", "b"},
2604 "group2": []any{"c", "d", "e"},
2605 },
2606 expected: map[string][]string{
2607 "group1": {"a", "b"},
2608 "group2": {"c", "d", "e"},
2609 },
2610 },
2611 {
2612 name: "invalid type",
2613 input: "not a map",
2614 expected: map[string][]string{},
2615 },
2616 {
2617 name: "non-array value",
2618 input: map[string]any{
2619 "array": []any{"a", "b"},
2620 "string": "not an array",
2621 },
2622 expected: map[string][]string{
2623 "array": {"a", "b"},
2624 },
2625 },
2626 }
2627
2628 for _, tt := range tests {
2629 t.Run(tt.name, func(t *testing.T) {
2630 result := loadStringArrayMap(tt.input)
2631 assert.Equal(t, tt.expected, result)
2632 })
2633 }
2634}