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