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