1package config
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "github.com/bnema/zerowrap"
11 "github.com/spf13/viper"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19func testContext() context.Context {
20 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
21}
22
23func TestService_Load(t *testing.T) {
24 v := viper.New()
25 v.Set("server.port", 8080)
26 v.Set("server.registry_port", 5000)
27 v.Set("server.registry_domain", "registry.example.com")
28 v.Set("server.data_dir", "/var/gordon")
29 v.Set("auto_route.enabled", true)
30 v.Set("network_isolation.enabled", true)
31 v.Set("network_isolation.network_prefix", "gordon")
32 v.Set("auth.enabled", true)
33 v.Set("auth.username", "admin")
34 v.Set("auth.password", "secret")
35 v.Set("volumes.auto_create", true)
36 v.Set("volumes.prefix", "gordon")
37 v.Set("volumes.preserve", false)
38 v.Set("routes", map[string]interface{}{
39 "app.example.com": "myapp:latest",
40 })
41
42 eventBus := mocks.NewMockEventPublisher(t)
43 svc := NewService(v, eventBus)
44 ctx := testContext()
45
46 err := svc.Load(ctx)
47
48 assert.NoError(t, err)
49 assert.Equal(t, 8080, svc.GetServerPort())
50 assert.Equal(t, 5000, svc.GetRegistryPort())
51 assert.Equal(t, "registry.example.com", svc.GetRegistryDomain())
52 assert.Equal(t, "/var/gordon", svc.GetDataDir())
53 assert.True(t, svc.IsAutoRouteEnabled())
54 assert.True(t, svc.IsNetworkIsolationEnabled())
55 assert.Equal(t, "gordon", svc.GetNetworkPrefix())
56
57 autoCreate, prefix, preserve := svc.GetVolumeConfig()
58 assert.True(t, autoCreate)
59 assert.Equal(t, "gordon", prefix)
60 assert.False(t, preserve)
61}
62
63func TestService_Reload(t *testing.T) {
64 t.Run("success - picks up config file changes", func(t *testing.T) {
65 // Create temp config file
66 tmpDir := t.TempDir()
67 configFile := filepath.Join(tmpDir, "gordon.toml")
68 initialConfig := `[server]
69port = 8080
70
71[routes]
72"app.example.com" = "myapp:v1"
73`
74 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
75 require.NoError(t, err)
76
77 v := viper.New()
78 v.SetConfigFile(configFile)
79 err = v.ReadInConfig()
80 require.NoError(t, err)
81
82 eventBus := mocks.NewMockEventPublisher(t)
83 svc := NewService(v, eventBus)
84 ctx := testContext()
85
86 // Initial load
87 err = svc.Load(ctx)
88 require.NoError(t, err)
89 assert.Equal(t, 8080, svc.GetServerPort())
90 assert.Equal(t, "myapp:v1", svc.GetConfig().Routes["app.example.com"])
91
92 // Modify config file on disk
93 updatedConfig := `[server]
94port = 9090
95
96[routes]
97"app.example.com" = "myapp:v2"
98"new.example.com" = "newapp:latest"
99`
100 err = os.WriteFile(configFile, []byte(updatedConfig), 0600)
101 require.NoError(t, err)
102
103 // Reload should pick up new values
104 err = svc.Reload(ctx)
105 require.NoError(t, err)
106
107 assert.Equal(t, 9090, svc.GetServerPort())
108 assert.Equal(t, "myapp:v2", svc.GetConfig().Routes["app.example.com"])
109 assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
110 })
111
112 t.Run("error - config file not found", func(t *testing.T) {
113 tmpDir := t.TempDir()
114 configFile := filepath.Join(tmpDir, "gordon.toml")
115
116 // Create config, load it, then delete it
117 err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
118 require.NoError(t, err)
119
120 v := viper.New()
121 v.SetConfigFile(configFile)
122 err = v.ReadInConfig()
123 require.NoError(t, err)
124
125 eventBus := mocks.NewMockEventPublisher(t)
126 svc := NewService(v, eventBus)
127 ctx := testContext()
128
129 err = svc.Load(ctx)
130 require.NoError(t, err)
131
132 // Delete the config file
133 err = os.Remove(configFile)
134 require.NoError(t, err)
135
136 // Reload should fail
137 err = svc.Reload(ctx)
138 assert.Error(t, err)
139 assert.Contains(t, err.Error(), "failed to read config file")
140 })
141
142 t.Run("error - invalid config syntax", func(t *testing.T) {
143 tmpDir := t.TempDir()
144 configFile := filepath.Join(tmpDir, "gordon.toml")
145
146 // Create valid config initially
147 err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
148 require.NoError(t, err)
149
150 v := viper.New()
151 v.SetConfigFile(configFile)
152 err = v.ReadInConfig()
153 require.NoError(t, err)
154
155 eventBus := mocks.NewMockEventPublisher(t)
156 svc := NewService(v, eventBus)
157 ctx := testContext()
158
159 err = svc.Load(ctx)
160 require.NoError(t, err)
161
162 // Write invalid TOML
163 err = os.WriteFile(configFile, []byte("[server\nport = invalid syntax"), 0600)
164 require.NoError(t, err)
165
166 // Reload should fail
167 err = svc.Reload(ctx)
168 assert.Error(t, err)
169 })
170}
171
172func TestService_GetRoutes(t *testing.T) {
173 v := viper.New()
174 v.Set("routes", map[string]interface{}{
175 "app1.example.com": "myapp:latest",
176 "app2.example.com": "otherapp:v2",
177 "http://insecure.example": "insecureapp:latest",
178 })
179
180 eventBus := mocks.NewMockEventPublisher(t)
181 svc := NewService(v, eventBus)
182 ctx := testContext()
183
184 _ = svc.Load(ctx)
185
186 routes := svc.GetRoutes(ctx)
187
188 assert.Len(t, routes, 3)
189
190 // Check routes (order is not guaranteed)
191 routeMap := make(map[string]domain.Route)
192 for _, r := range routes {
193 routeMap[r.Domain] = r
194 }
195
196 assert.Equal(t, "myapp:latest", routeMap["app1.example.com"].Image)
197 assert.True(t, routeMap["app1.example.com"].HTTPS)
198
199 assert.Equal(t, "otherapp:v2", routeMap["app2.example.com"].Image)
200 assert.True(t, routeMap["app2.example.com"].HTTPS)
201
202 assert.Equal(t, "insecureapp:latest", routeMap["insecure.example"].Image)
203 assert.False(t, routeMap["insecure.example"].HTTPS)
204}
205
206func TestService_GetRoute(t *testing.T) {
207 v := viper.New()
208 v.Set("routes", map[string]interface{}{
209 "app.example.com": "myapp:latest",
210 "http://insecure.example": "insecureapp:latest",
211 })
212
213 eventBus := mocks.NewMockEventPublisher(t)
214 svc := NewService(v, eventBus)
215 ctx := testContext()
216
217 _ = svc.Load(ctx)
218
219 t.Run("existing route", func(t *testing.T) {
220 route, err := svc.GetRoute(ctx, "app.example.com")
221 require.NoError(t, err)
222 assert.Equal(t, "app.example.com", route.Domain)
223 assert.Equal(t, "myapp:latest", route.Image)
224 assert.True(t, route.HTTPS)
225 })
226
227 t.Run("non-existent route", func(t *testing.T) {
228 route, err := svc.GetRoute(ctx, "notfound.example.com")
229 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
230 assert.Nil(t, route)
231 })
232}
233
234func TestService_AddRoute(t *testing.T) {
235 t.Run("success", func(t *testing.T) {
236 // Create temp config file for Save() to work
237 tmpDir := t.TempDir()
238 configFile := filepath.Join(tmpDir, "gordon.toml")
239 err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
240 require.NoError(t, err)
241
242 v := viper.New()
243 v.SetConfigFile(configFile)
244 eventBus := mocks.NewMockEventPublisher(t)
245 svc := NewService(v, eventBus)
246 ctx := testContext()
247
248 _ = svc.Load(ctx)
249
250 route := domain.Route{
251 Domain: "new.example.com",
252 Image: "newapp:latest",
253 }
254
255 err = svc.AddRoute(ctx, route)
256
257 assert.NoError(t, err)
258
259 // Verify route was added
260 config := svc.GetConfig()
261 assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
262 })
263
264 t.Run("idempotent same domain and image saves once", func(t *testing.T) {
265 tmpDir := t.TempDir()
266 configFile := filepath.Join(tmpDir, "gordon.toml")
267 err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
268 require.NoError(t, err)
269
270 v := viper.New()
271 v.SetConfigFile(configFile)
272 eventBus := mocks.NewMockEventPublisher(t)
273 svc := NewService(v, eventBus)
274 ctx := testContext()
275
276 _ = svc.Load(ctx)
277
278 route := domain.Route{
279 Domain: "new.example.com",
280 Image: "newapp:latest",
281 }
282
283 err = svc.AddRoute(ctx, route)
284 require.NoError(t, err)
285
286 before, err := os.Stat(configFile)
287 require.NoError(t, err)
288
289 err = svc.AddRoute(ctx, route)
290 require.NoError(t, err)
291
292 after, err := os.Stat(configFile)
293 require.NoError(t, err)
294
295 assert.Equal(t, before.ModTime(), after.ModTime())
296 assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
297 })
298
299 t.Run("empty domain", func(t *testing.T) {
300 v := viper.New()
301 eventBus := mocks.NewMockEventPublisher(t)
302 svc := NewService(v, eventBus)
303 ctx := testContext()
304
305 route := domain.Route{
306 Domain: "",
307 Image: "myapp:latest",
308 }
309
310 err := svc.AddRoute(ctx, route)
311 assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
312 })
313
314 t.Run("empty image", func(t *testing.T) {
315 v := viper.New()
316 eventBus := mocks.NewMockEventPublisher(t)
317 svc := NewService(v, eventBus)
318 ctx := testContext()
319
320 route := domain.Route{
321 Domain: "example.com",
322 Image: "",
323 }
324
325 err := svc.AddRoute(ctx, route)
326 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
327 })
328}
329
330func TestService_UpdateRoute(t *testing.T) {
331 t.Run("success", func(t *testing.T) {
332 // Create temp config file for Save() to work
333 tmpDir := t.TempDir()
334 configFile := filepath.Join(tmpDir, "gordon.toml")
335 err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600)
336 require.NoError(t, err)
337
338 v := viper.New()
339 v.SetConfigFile(configFile)
340 v.Set("routes", map[string]any{
341 "app.example.com": "myapp:v1",
342 })
343
344 eventBus := mocks.NewMockEventPublisher(t)
345 svc := NewService(v, eventBus)
346 ctx := testContext()
347
348 _ = svc.Load(ctx)
349
350 route := domain.Route{
351 Domain: "app.example.com",
352 Image: "myapp:v2",
353 }
354
355 err = svc.UpdateRoute(ctx, route)
356
357 assert.NoError(t, err)
358
359 config := svc.GetConfig()
360 assert.Equal(t, "myapp:v2", config.Routes["app.example.com"])
361 })
362
363 t.Run("not found", func(t *testing.T) {
364 v := viper.New()
365 eventBus := mocks.NewMockEventPublisher(t)
366 svc := NewService(v, eventBus)
367 ctx := testContext()
368
369 _ = svc.Load(ctx)
370
371 route := domain.Route{
372 Domain: "nonexistent.example.com",
373 Image: "myapp:latest",
374 }
375
376 err := svc.UpdateRoute(ctx, route)
377 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
378 })
379
380 t.Run("empty domain", func(t *testing.T) {
381 v := viper.New()
382 eventBus := mocks.NewMockEventPublisher(t)
383 svc := NewService(v, eventBus)
384 ctx := testContext()
385
386 route := domain.Route{
387 Domain: "",
388 Image: "myapp:latest",
389 }
390
391 err := svc.UpdateRoute(ctx, route)
392 assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
393 })
394
395 t.Run("empty image", func(t *testing.T) {
396 v := viper.New()
397 eventBus := mocks.NewMockEventPublisher(t)
398 svc := NewService(v, eventBus)
399 ctx := testContext()
400
401 route := domain.Route{
402 Domain: "example.com",
403 Image: "",
404 }
405
406 err := svc.UpdateRoute(ctx, route)
407 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
408 })
409}
410
411func TestService_RemoveRoute(t *testing.T) {
412 // Create temp config file for Save() to work
413 tmpDir := t.TempDir()
414 configFile := filepath.Join(tmpDir, "gordon.toml")
415 err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
416 require.NoError(t, err)
417
418 v := viper.New()
419 v.SetConfigFile(configFile)
420 v.Set("routes", map[string]interface{}{
421 "app.example.com": "myapp:latest",
422 })
423
424 eventBus := mocks.NewMockEventPublisher(t)
425 svc := NewService(v, eventBus)
426 ctx := testContext()
427
428 _ = svc.Load(ctx)
429
430 err = svc.RemoveRoute(ctx, "app.example.com")
431
432 assert.NoError(t, err)
433
434 config := svc.GetConfig()
435 _, exists := config.Routes["app.example.com"]
436 assert.False(t, exists)
437}
438
439func TestService_RemoveRoute_NotFound(t *testing.T) {
440 v := viper.New()
441 eventBus := mocks.NewMockEventPublisher(t)
442 svc := NewService(v, eventBus)
443 ctx := testContext()
444
445 _ = svc.Load(ctx)
446
447 err := svc.RemoveRoute(ctx, "nonexistent.example.com")
448
449 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
450}
451
452func TestService_GetNetworkGroups(t *testing.T) {
453 v := viper.New()
454 v.Set("network_groups", map[string]interface{}{
455 "frontend": []interface{}{"app1.example.com", "app2.example.com"},
456 "backend": []interface{}{"api.example.com", "db.example.com"},
457 })
458
459 eventBus := mocks.NewMockEventPublisher(t)
460 svc := NewService(v, eventBus)
461 ctx := testContext()
462
463 _ = svc.Load(ctx)
464
465 groups := svc.GetNetworkGroups()
466
467 assert.Len(t, groups, 2)
468 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
469 assert.ElementsMatch(t, []string{"api.example.com", "db.example.com"}, groups["backend"])
470}
471
472func TestService_GetAttachmentConfig(t *testing.T) {
473 v := viper.New()
474 v.Set("attachments", map[string]interface{}{
475 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
476 })
477 v.Set("network_groups", map[string]interface{}{
478 "shared": []interface{}{"app.example.com", "api.example.com"},
479 })
480
481 eventBus := mocks.NewMockEventPublisher(t)
482 svc := NewService(v, eventBus)
483 ctx := testContext()
484
485 _ = svc.Load(ctx)
486
487 snapshot := svc.GetAttachmentConfig()
488
489 assert.Len(t, snapshot.Attachments, 1)
490 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, snapshot.Attachments["app.example.com"])
491 assert.Len(t, snapshot.NetworkGroups, 1)
492 assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, snapshot.NetworkGroups["shared"])
493
494 snapshot.Attachments["app.example.com"][0] = "mutated"
495 snapshot.NetworkGroups["shared"][0] = "mutated.example.com"
496
497 freshSnapshot := svc.GetAttachmentConfig()
498 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, freshSnapshot.Attachments["app.example.com"])
499 assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, freshSnapshot.NetworkGroups["shared"])
500}
501
502func TestService_GetAllAttachments(t *testing.T) {
503 t.Run("returns all attachments", func(t *testing.T) {
504 v := viper.New()
505 v.Set("attachments", map[string]interface{}{
506 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
507 "api.example.com": []interface{}{"rabbitmq:3"},
508 })
509
510 eventBus := mocks.NewMockEventPublisher(t)
511 svc := NewService(v, eventBus)
512 ctx := testContext()
513
514 _ = svc.Load(ctx)
515
516 attachments := svc.GetAllAttachments(ctx)
517
518 assert.Len(t, attachments, 2)
519 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
520 assert.ElementsMatch(t, []string{"rabbitmq:3"}, attachments["api.example.com"])
521 })
522
523 t.Run("returns empty map when no attachments", func(t *testing.T) {
524 v := viper.New()
525 eventBus := mocks.NewMockEventPublisher(t)
526 svc := NewService(v, eventBus)
527 ctx := testContext()
528
529 _ = svc.Load(ctx)
530
531 attachments := svc.GetAllAttachments(ctx)
532
533 assert.Empty(t, attachments)
534 })
535
536 t.Run("returns copy not reference", func(t *testing.T) {
537 v := viper.New()
538 v.Set("attachments", map[string]interface{}{
539 "app.example.com": []interface{}{"redis:latest"},
540 })
541
542 eventBus := mocks.NewMockEventPublisher(t)
543 svc := NewService(v, eventBus)
544 ctx := testContext()
545
546 _ = svc.Load(ctx)
547
548 attachments := svc.GetAllAttachments(ctx)
549 // Modify the returned map
550 attachments["app.example.com"] = append(attachments["app.example.com"], "postgres:18")
551
552 // Original should be unchanged
553 original := svc.GetAllAttachments(ctx)
554 assert.Len(t, original["app.example.com"], 1)
555 })
556}
557
558func TestService_GetAttachmentsFor(t *testing.T) {
559 t.Run("existing domain", func(t *testing.T) {
560 v := viper.New()
561 v.Set("attachments", map[string]interface{}{
562 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
563 })
564
565 eventBus := mocks.NewMockEventPublisher(t)
566 svc := NewService(v, eventBus)
567 ctx := testContext()
568
569 _ = svc.Load(ctx)
570
571 images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
572
573 require.NoError(t, err)
574 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, images)
575 })
576
577 t.Run("existing network group", func(t *testing.T) {
578 v := viper.New()
579 v.Set("attachments", map[string]interface{}{
580 "backend": []interface{}{"rabbitmq:3", "redis:latest"},
581 })
582
583 eventBus := mocks.NewMockEventPublisher(t)
584 svc := NewService(v, eventBus)
585 ctx := testContext()
586
587 _ = svc.Load(ctx)
588
589 images, err := svc.GetAttachmentsFor(ctx, "backend")
590
591 require.NoError(t, err)
592 assert.ElementsMatch(t, []string{"rabbitmq:3", "redis:latest"}, images)
593 })
594
595 t.Run("non-existent target", func(t *testing.T) {
596 v := viper.New()
597 eventBus := mocks.NewMockEventPublisher(t)
598 svc := NewService(v, eventBus)
599 ctx := testContext()
600
601 _ = svc.Load(ctx)
602
603 images, err := svc.GetAttachmentsFor(ctx, "notfound.example.com")
604
605 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
606 assert.Nil(t, images)
607 })
608
609 t.Run("returns copy not reference", func(t *testing.T) {
610 v := viper.New()
611 v.Set("attachments", map[string]interface{}{
612 "app.example.com": []interface{}{"redis:latest"},
613 })
614
615 eventBus := mocks.NewMockEventPublisher(t)
616 svc := NewService(v, eventBus)
617 ctx := testContext()
618
619 _ = svc.Load(ctx)
620
621 images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
622 require.NoError(t, err)
623
624 // Modify the returned slice (use _ to satisfy linter)
625 _ = append(images, "postgres:18")
626
627 // Original should be unchanged
628 original, _ := svc.GetAttachmentsFor(ctx, "app.example.com")
629 assert.Len(t, original, 1)
630 })
631}
632
633func TestService_AddAttachment(t *testing.T) {
634 t.Run("success - new target", func(t *testing.T) {
635 tmpDir := t.TempDir()
636 configFile := filepath.Join(tmpDir, "gordon.toml")
637 err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
638 require.NoError(t, err)
639
640 v := viper.New()
641 v.SetConfigFile(configFile)
642 eventBus := mocks.NewMockEventPublisher(t)
643 svc := NewService(v, eventBus)
644 ctx := testContext()
645
646 _ = svc.Load(ctx)
647
648 err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
649
650 assert.NoError(t, err)
651
652 // Verify attachment was added
653 config := svc.GetConfig()
654 assert.Contains(t, config.Attachments["app.example.com"], "postgres:18")
655 })
656
657 t.Run("success - existing target", func(t *testing.T) {
658 tmpDir := t.TempDir()
659 configFile := filepath.Join(tmpDir, "gordon.toml")
660 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
661 require.NoError(t, err)
662
663 v := viper.New()
664 v.SetConfigFile(configFile)
665 v.Set("attachments", map[string]interface{}{
666 "app.example.com": []interface{}{"redis:latest"},
667 })
668 eventBus := mocks.NewMockEventPublisher(t)
669 svc := NewService(v, eventBus)
670 ctx := testContext()
671
672 _ = svc.Load(ctx)
673
674 err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
675
676 assert.NoError(t, err)
677
678 // Verify attachment was added alongside existing
679 config := svc.GetConfig()
680 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
681 })
682
683 t.Run("duplicate attachment", func(t *testing.T) {
684 v := viper.New()
685 v.Set("attachments", map[string]interface{}{
686 "app.example.com": []interface{}{"redis:latest"},
687 })
688 eventBus := mocks.NewMockEventPublisher(t)
689 svc := NewService(v, eventBus)
690 ctx := testContext()
691
692 _ = svc.Load(ctx)
693
694 err := svc.AddAttachment(ctx, "app.example.com", "redis:latest")
695
696 assert.ErrorIs(t, err, domain.ErrAttachmentExists)
697 })
698
699 t.Run("empty target", func(t *testing.T) {
700 v := viper.New()
701 eventBus := mocks.NewMockEventPublisher(t)
702 svc := NewService(v, eventBus)
703 ctx := testContext()
704
705 err := svc.AddAttachment(ctx, "", "postgres:18")
706
707 assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
708 })
709
710 t.Run("empty image", func(t *testing.T) {
711 v := viper.New()
712 eventBus := mocks.NewMockEventPublisher(t)
713 svc := NewService(v, eventBus)
714 ctx := testContext()
715
716 err := svc.AddAttachment(ctx, "app.example.com", "")
717
718 assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
719 })
720
721 t.Run("network group target", func(t *testing.T) {
722 tmpDir := t.TempDir()
723 configFile := filepath.Join(tmpDir, "gordon.toml")
724 err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
725 require.NoError(t, err)
726
727 v := viper.New()
728 v.SetConfigFile(configFile)
729 eventBus := mocks.NewMockEventPublisher(t)
730 svc := NewService(v, eventBus)
731 ctx := testContext()
732
733 _ = svc.Load(ctx)
734
735 err = svc.AddAttachment(ctx, "backend", "rabbitmq:3")
736
737 assert.NoError(t, err)
738
739 config := svc.GetConfig()
740 assert.Contains(t, config.Attachments["backend"], "rabbitmq:3")
741 })
742}
743
744func TestService_RemoveAttachment(t *testing.T) {
745 t.Run("success - single attachment", func(t *testing.T) {
746 tmpDir := t.TempDir()
747 configFile := filepath.Join(tmpDir, "gordon.toml")
748 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
749 require.NoError(t, err)
750
751 v := viper.New()
752 v.SetConfigFile(configFile)
753 v.Set("attachments", map[string]interface{}{
754 "app.example.com": []interface{}{"redis:latest"},
755 })
756 eventBus := mocks.NewMockEventPublisher(t)
757 svc := NewService(v, eventBus)
758 ctx := testContext()
759
760 _ = svc.Load(ctx)
761
762 err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
763
764 assert.NoError(t, err)
765
766 // Verify target was removed (no attachments left)
767 config := svc.GetConfig()
768 _, exists := config.Attachments["app.example.com"]
769 assert.False(t, exists)
770 })
771
772 t.Run("success - multiple attachments", func(t *testing.T) {
773 tmpDir := t.TempDir()
774 configFile := filepath.Join(tmpDir, "gordon.toml")
775 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\", \"postgres:18\"]\n"), 0600)
776 require.NoError(t, err)
777
778 v := viper.New()
779 v.SetConfigFile(configFile)
780 v.Set("attachments", map[string]interface{}{
781 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
782 })
783 eventBus := mocks.NewMockEventPublisher(t)
784 svc := NewService(v, eventBus)
785 ctx := testContext()
786
787 _ = svc.Load(ctx)
788
789 err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
790
791 assert.NoError(t, err)
792
793 // Verify only redis was removed
794 config := svc.GetConfig()
795 assert.Equal(t, []string{"postgres:18"}, config.Attachments["app.example.com"])
796 })
797
798 t.Run("target not found", func(t *testing.T) {
799 v := viper.New()
800 eventBus := mocks.NewMockEventPublisher(t)
801 svc := NewService(v, eventBus)
802 ctx := testContext()
803
804 _ = svc.Load(ctx)
805
806 err := svc.RemoveAttachment(ctx, "notfound.example.com", "redis:latest")
807
808 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
809 })
810
811 t.Run("image not found in target", func(t *testing.T) {
812 v := viper.New()
813 v.Set("attachments", map[string]interface{}{
814 "app.example.com": []interface{}{"redis:latest"},
815 })
816 eventBus := mocks.NewMockEventPublisher(t)
817 svc := NewService(v, eventBus)
818 ctx := testContext()
819
820 _ = svc.Load(ctx)
821
822 err := svc.RemoveAttachment(ctx, "app.example.com", "postgres:18")
823
824 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
825 })
826
827 t.Run("empty target", func(t *testing.T) {
828 v := viper.New()
829 eventBus := mocks.NewMockEventPublisher(t)
830 svc := NewService(v, eventBus)
831 ctx := testContext()
832
833 err := svc.RemoveAttachment(ctx, "", "redis:latest")
834
835 assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
836 })
837
838 t.Run("empty image", func(t *testing.T) {
839 v := viper.New()
840 eventBus := mocks.NewMockEventPublisher(t)
841 svc := NewService(v, eventBus)
842 ctx := testContext()
843
844 err := svc.RemoveAttachment(ctx, "app.example.com", "")
845
846 assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
847 })
848
849 t.Run("network group target", func(t *testing.T) {
850 tmpDir := t.TempDir()
851 configFile := filepath.Join(tmpDir, "gordon.toml")
852 err := os.WriteFile(configFile, []byte("[attachments]\n\"backend\" = [\"rabbitmq:3\"]\n"), 0600)
853 require.NoError(t, err)
854
855 v := viper.New()
856 v.SetConfigFile(configFile)
857 v.Set("attachments", map[string]interface{}{
858 "backend": []interface{}{"rabbitmq:3"},
859 })
860 eventBus := mocks.NewMockEventPublisher(t)
861 svc := NewService(v, eventBus)
862 ctx := testContext()
863
864 _ = svc.Load(ctx)
865
866 err = svc.RemoveAttachment(ctx, "backend", "rabbitmq:3")
867
868 assert.NoError(t, err)
869
870 config := svc.GetConfig()
871 _, exists := config.Attachments["backend"]
872 assert.False(t, exists)
873 })
874}
875
876func TestService_AutoRouteAllowedDomains(t *testing.T) {
877 t.Run("get returns copy", func(t *testing.T) {
878 v := viper.New()
879 v.Set("auto_route_allowed_domains", []string{"example.com", "*.example.com"})
880
881 eventBus := mocks.NewMockEventPublisher(t)
882 svc := NewService(v, eventBus)
883 ctx := testContext()
884
885 require.NoError(t, svc.Load(ctx))
886
887 domains, err := svc.GetAutoRouteAllowedDomains(ctx)
888 require.NoError(t, err)
889 domains[0] = "changed.com"
890
891 again, err := svc.GetAutoRouteAllowedDomains(ctx)
892 require.NoError(t, err)
893 assert.Equal(t, []string{"example.com", "*.example.com"}, again)
894 })
895
896 t.Run("add and remove persist idempotently", func(t *testing.T) {
897 tmpDir := t.TempDir()
898 configFile := filepath.Join(tmpDir, "gordon.toml")
899 require.NoError(t, os.WriteFile(configFile, []byte("auto_route_allowed_domains = [\"example.com\"]\n"), 0600))
900
901 v := viper.New()
902 v.SetConfigFile(configFile)
903 v.Set("auto_route_allowed_domains", []interface{}{"example.com"})
904
905 eventBus := mocks.NewMockEventPublisher(t)
906 svc := NewService(v, eventBus)
907 ctx := testContext()
908
909 require.NoError(t, svc.Load(ctx))
910 require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
911 require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
912
913 domains, err := svc.GetAutoRouteAllowedDomains(ctx)
914 require.NoError(t, err)
915 assert.Equal(t, []string{"example.com", "*.example.com"}, domains)
916
917 require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "example.com"))
918 require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "missing.com"))
919
920 domains, err = svc.GetAutoRouteAllowedDomains(ctx)
921 require.NoError(t, err)
922 assert.Equal(t, []string{"*.example.com"}, domains)
923 })
924
925 t.Run("add validates patterns", func(t *testing.T) {
926 v := viper.New()
927 eventBus := mocks.NewMockEventPublisher(t)
928 svc := NewService(v, eventBus)
929 ctx := testContext()
930 require.NoError(t, svc.Load(ctx))
931
932 invalid := []string{"", "EXAMPLE.com", "example.com.", "**.example.com", "foo.*.example.com", "*.example.com."}
933 for _, pattern := range invalid {
934 err := svc.AddAutoRouteAllowedDomain(ctx, pattern)
935 assert.ErrorIs(t, err, domain.ErrInvalidDomainPattern, "pattern: %s", pattern)
936 }
937 })
938}
939
940func TestService_GetExternalRoutes(t *testing.T) {
941 v := viper.New()
942 v.Set("external_routes", map[string]interface{}{
943 "reg.example.com": "localhost:5000",
944 "cache.example.com": "127.0.0.1:6379",
945 })
946
947 eventBus := mocks.NewMockEventPublisher(t)
948 svc := NewService(v, eventBus)
949 ctx := testContext()
950
951 _ = svc.Load(ctx)
952
953 routes := svc.GetExternalRoutes()
954
955 assert.Len(t, routes, 2)
956 assert.Equal(t, "localhost:5000", routes["reg.example.com"])
957 assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
958}
959
960func TestService_GetExternalRoutes_Empty(t *testing.T) {
961 v := viper.New()
962
963 eventBus := mocks.NewMockEventPublisher(t)
964 svc := NewService(v, eventBus)
965 ctx := testContext()
966
967 _ = svc.Load(ctx)
968
969 routes := svc.GetExternalRoutes()
970
971 assert.Empty(t, routes)
972}
973
974func TestSplitImageNameTag(t *testing.T) {
975 tests := []struct {
976 name string
977 image string
978 wantName string
979 wantHasTag bool
980 }{
981 {"with tag", "myapp:latest", "myapp", true},
982 {"with version tag", "myapp:v1.2.0", "myapp", true},
983 {"no tag", "myapp", "myapp", false},
984 {"empty string", "", "", false},
985 {"tag only colon", "myapp:", "myapp", true},
986 {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true},
987 {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false},
988 }
989
990 for _, tt := range tests {
991 t.Run(tt.name, func(t *testing.T) {
992 name, hasTag := splitImageNameTag(tt.image)
993 assert.Equal(t, tt.wantName, name)
994 assert.Equal(t, tt.wantHasTag, hasTag)
995 })
996 }
997}
998
999func TestNormalizeRegistryImage(t *testing.T) {
1000 tests := []struct {
1001 name string
1002 imageName string
1003 registryDomain string
1004 expected string
1005 }{
1006 {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
1007 {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
1008 {"empty registry domain", "myapp:latest", "", "myapp:latest"},
1009 {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
1010 {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
1011 {"bare name no registry", "myapp", "reg.example.com", "myapp"},
1012 {"exact registry without image", "reg.example.com/", "reg.example.com", ""},
1013 }
1014
1015 for _, tt := range tests {
1016 t.Run(tt.name, func(t *testing.T) {
1017 result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
1018 assert.Equal(t, tt.expected, result)
1019 })
1020 }
1021}
1022
1023func TestNormalizeBootstrapImage(t *testing.T) {
1024 tests := []struct {
1025 name string
1026 image string
1027 registryDomain string
1028 expected string
1029 wantErr string
1030 }{
1031 {name: "bare name prepends registry", image: "myapp", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1032 {name: "bare name strips tag", image: "myapp:v1", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1033 {name: "qualified image unchanged", image: "reg.example.com/myapp", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1034 {name: "qualified image strips tag", image: "reg.example.com/myapp:abc123", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1035 {name: "other registry strips tag", image: "other.registry.com/myapp:v2", registryDomain: "reg.example.com", expected: "other.registry.com/myapp"},
1036 {name: "empty image", image: "", registryDomain: "reg.example.com", wantErr: "image is required"},
1037 {name: "empty registry domain", image: "pitlane", registryDomain: "", wantErr: "registry domain is not configured"},
1038 }
1039
1040 for _, tt := range tests {
1041 t.Run(tt.name, func(t *testing.T) {
1042 result, err := NormalizeBootstrapImage(tt.image, tt.registryDomain)
1043 if tt.wantErr != "" {
1044 require.Error(t, err)
1045 assert.Equal(t, tt.wantErr, err.Error())
1046 assert.Empty(t, result)
1047 return
1048 }
1049
1050 require.NoError(t, err)
1051 assert.Equal(t, tt.expected, result)
1052 })
1053 }
1054}
1055
1056func TestService_FindRoutesByImage(t *testing.T) {
1057 t.Run("exact match with tag", func(t *testing.T) {
1058 v := viper.New()
1059 v.Set("routes", map[string]interface{}{
1060 "app.example.com": "myapp:latest",
1061 })
1062
1063 eventBus := mocks.NewMockEventPublisher(t)
1064 svc := NewService(v, eventBus)
1065 ctx := testContext()
1066 _ = svc.Load(ctx)
1067
1068 routes := svc.FindRoutesByImage(ctx, "myapp:latest")
1069 assert.Len(t, routes, 1)
1070 assert.Equal(t, "app.example.com", routes[0].Domain)
1071 assert.Equal(t, "myapp:latest", routes[0].Image)
1072 })
1073
1074 t.Run("bare name matches route with tag", func(t *testing.T) {
1075 v := viper.New()
1076 v.Set("routes", map[string]interface{}{
1077 "app.example.com": "myapp:latest",
1078 })
1079
1080 eventBus := mocks.NewMockEventPublisher(t)
1081 svc := NewService(v, eventBus)
1082 ctx := testContext()
1083 _ = svc.Load(ctx)
1084
1085 routes := svc.FindRoutesByImage(ctx, "myapp")
1086 assert.Len(t, routes, 1)
1087 assert.Equal(t, "app.example.com", routes[0].Domain)
1088 })
1089
1090 t.Run("bare name matches route with version tag", func(t *testing.T) {
1091 v := viper.New()
1092 v.Set("routes", map[string]interface{}{
1093 "app.example.com": "myapp:v2.0.0",
1094 })
1095
1096 eventBus := mocks.NewMockEventPublisher(t)
1097 svc := NewService(v, eventBus)
1098 ctx := testContext()
1099 _ = svc.Load(ctx)
1100
1101 routes := svc.FindRoutesByImage(ctx, "myapp")
1102 assert.Len(t, routes, 1)
1103 assert.Equal(t, "app.example.com", routes[0].Domain)
1104 })
1105
1106 t.Run("tag mismatch does not match", func(t *testing.T) {
1107 v := viper.New()
1108 v.Set("routes", map[string]interface{}{
1109 "app.example.com": "myapp:v1",
1110 })
1111
1112 eventBus := mocks.NewMockEventPublisher(t)
1113 svc := NewService(v, eventBus)
1114 ctx := testContext()
1115 _ = svc.Load(ctx)
1116
1117 routes := svc.FindRoutesByImage(ctx, "myapp:v2")
1118 assert.Empty(t, routes)
1119 })
1120
1121 t.Run("no match", func(t *testing.T) {
1122 v := viper.New()
1123 v.Set("routes", map[string]interface{}{
1124 "app.example.com": "myapp:latest",
1125 })
1126
1127 eventBus := mocks.NewMockEventPublisher(t)
1128 svc := NewService(v, eventBus)
1129 ctx := testContext()
1130 _ = svc.Load(ctx)
1131
1132 routes := svc.FindRoutesByImage(ctx, "otherapp")
1133 assert.Empty(t, routes)
1134 })
1135
1136 t.Run("strips registry prefix before matching", func(t *testing.T) {
1137 v := viper.New()
1138 v.Set("server.registry_domain", "reg.example.com")
1139 v.Set("routes", map[string]interface{}{
1140 "app.example.com": "reg.example.com/myapp:latest",
1141 })
1142
1143 eventBus := mocks.NewMockEventPublisher(t)
1144 svc := NewService(v, eventBus)
1145 ctx := testContext()
1146 _ = svc.Load(ctx)
1147
1148 routes := svc.FindRoutesByImage(ctx, "myapp")
1149 assert.Len(t, routes, 1)
1150 assert.Equal(t, "app.example.com", routes[0].Domain)
1151 })
1152
1153 t.Run("strips registry prefix from input too", func(t *testing.T) {
1154 v := viper.New()
1155 v.Set("server.registry_domain", "reg.example.com")
1156 v.Set("routes", map[string]interface{}{
1157 "app.example.com": "reg.example.com/myapp:latest",
1158 })
1159
1160 eventBus := mocks.NewMockEventPublisher(t)
1161 svc := NewService(v, eventBus)
1162 ctx := testContext()
1163 _ = svc.Load(ctx)
1164
1165 routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest")
1166 assert.Len(t, routes, 1)
1167 assert.Equal(t, "app.example.com", routes[0].Domain)
1168 })
1169
1170 t.Run("multiple routes for same image", func(t *testing.T) {
1171 v := viper.New()
1172 v.Set("routes", map[string]interface{}{
1173 "app1.example.com": "myapp:latest",
1174 "app2.example.com": "myapp:latest",
1175 "other.example.com": "otherapp:latest",
1176 })
1177
1178 eventBus := mocks.NewMockEventPublisher(t)
1179 svc := NewService(v, eventBus)
1180 ctx := testContext()
1181 _ = svc.Load(ctx)
1182
1183 routes := svc.FindRoutesByImage(ctx, "myapp")
1184 assert.Len(t, routes, 2)
1185
1186 domains := []string{routes[0].Domain, routes[1].Domain}
1187 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains)
1188 })
1189
1190 t.Run("case insensitive match", func(t *testing.T) {
1191 v := viper.New()
1192 v.Set("routes", map[string]interface{}{
1193 "app.example.com": "MyApp:latest",
1194 })
1195
1196 eventBus := mocks.NewMockEventPublisher(t)
1197 svc := NewService(v, eventBus)
1198 ctx := testContext()
1199 _ = svc.Load(ctx)
1200
1201 routes := svc.FindRoutesByImage(ctx, "myapp")
1202 assert.Len(t, routes, 1)
1203 })
1204
1205 t.Run("http prefix route sets HTTPS false", func(t *testing.T) {
1206 v := viper.New()
1207 v.Set("routes", map[string]interface{}{
1208 "http://insecure.example.com": "myapp:latest",
1209 })
1210
1211 eventBus := mocks.NewMockEventPublisher(t)
1212 svc := NewService(v, eventBus)
1213 ctx := testContext()
1214 _ = svc.Load(ctx)
1215
1216 routes := svc.FindRoutesByImage(ctx, "myapp")
1217 assert.Len(t, routes, 1)
1218 assert.Equal(t, "insecure.example.com", routes[0].Domain)
1219 assert.False(t, routes[0].HTTPS)
1220 })
1221
1222 t.Run("empty routes", func(t *testing.T) {
1223 v := viper.New()
1224 eventBus := mocks.NewMockEventPublisher(t)
1225 svc := NewService(v, eventBus)
1226 ctx := testContext()
1227 _ = svc.Load(ctx)
1228
1229 routes := svc.FindRoutesByImage(ctx, "myapp")
1230 assert.Empty(t, routes)
1231 })
1232}
1233
1234func TestService_FindAttachmentTargetsByImage(t *testing.T) {
1235 t.Run("exact tagged matches", func(t *testing.T) {
1236 v := viper.New()
1237 v.Set("attachments", map[string]interface{}{
1238 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
1239 })
1240
1241 eventBus := mocks.NewMockEventPublisher(t)
1242 svc := NewService(v, eventBus)
1243 ctx := testContext()
1244 _ = svc.Load(ctx)
1245
1246 targets := svc.FindAttachmentTargetsByImage(ctx, "postgres:18")
1247 assert.Equal(t, []string{"app.example.com"}, targets)
1248 })
1249
1250 t.Run("bare name matches tagged attachments", func(t *testing.T) {
1251 v := viper.New()
1252 v.Set("attachments", map[string]interface{}{
1253 "app.example.com": []interface{}{"postgres:18"},
1254 })
1255
1256 eventBus := mocks.NewMockEventPublisher(t)
1257 svc := NewService(v, eventBus)
1258 ctx := testContext()
1259 _ = svc.Load(ctx)
1260
1261 targets := svc.FindAttachmentTargetsByImage(ctx, "postgres")
1262 assert.Equal(t, []string{"app.example.com"}, targets)
1263 })
1264
1265 t.Run("registry qualified input is normalized", func(t *testing.T) {
1266 v := viper.New()
1267 v.Set("server.registry_domain", "reg.example.com")
1268 v.Set("attachments", map[string]interface{}{
1269 "backend": []interface{}{"reg.example.com/postgres:18"},
1270 })
1271
1272 eventBus := mocks.NewMockEventPublisher(t)
1273 svc := NewService(v, eventBus)
1274 ctx := testContext()
1275 _ = svc.Load(ctx)
1276
1277 targets := svc.FindAttachmentTargetsByImage(ctx, "reg.example.com/postgres:18")
1278 assert.Equal(t, []string{"backend"}, targets)
1279 })
1280
1281 t.Run("shared attachment used by multiple targets", func(t *testing.T) {
1282 v := viper.New()
1283 v.Set("attachments", map[string]interface{}{
1284 "app.example.com": []interface{}{"redis:latest"},
1285 "backend": []interface{}{"redis:latest", "postgres:18"},
1286 "worker": []interface{}{"rabbitmq:3"},
1287 })
1288
1289 eventBus := mocks.NewMockEventPublisher(t)
1290 svc := NewService(v, eventBus)
1291 ctx := testContext()
1292 _ = svc.Load(ctx)
1293
1294 targets := svc.FindAttachmentTargetsByImage(ctx, "redis")
1295 assert.ElementsMatch(t, []string{"app.example.com", "backend"}, targets)
1296 })
1297
1298 t.Run("no matches", func(t *testing.T) {
1299 v := viper.New()
1300 v.Set("attachments", map[string]interface{}{
1301 "app.example.com": []interface{}{"redis:latest"},
1302 })
1303
1304 eventBus := mocks.NewMockEventPublisher(t)
1305 svc := NewService(v, eventBus)
1306 ctx := testContext()
1307 _ = svc.Load(ctx)
1308
1309 targets := svc.FindAttachmentTargetsByImage(ctx, "postgres")
1310 assert.Empty(t, targets)
1311 })
1312}
1313
1314func TestSavePreservesAllConfigFields(t *testing.T) {
1315 t.Run("does not write predictable tmp path", func(t *testing.T) {
1316 dir := t.TempDir()
1317 configFile := filepath.Join(dir, "gordon.toml")
1318 err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n\n[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
1319 require.NoError(t, err)
1320
1321 v := viper.New()
1322 v.SetConfigFile(configFile)
1323 err = v.ReadInConfig()
1324 require.NoError(t, err)
1325
1326 eventBus := mocks.NewMockEventPublisher(t)
1327 svc := NewService(v, eventBus)
1328 ctx := testContext()
1329 err = svc.Load(ctx)
1330 require.NoError(t, err)
1331
1332 maliciousTarget := filepath.Join(dir, "pwned.txt")
1333 predictableTmp := configFile + ".tmp"
1334 err = os.Symlink(maliciousTarget, predictableTmp)
1335 require.NoError(t, err)
1336
1337 err = svc.Save(ctx)
1338 require.NoError(t, err)
1339
1340 _, statErr := os.Stat(maliciousTarget)
1341 assert.True(t, os.IsNotExist(statErr), "symlink target should not exist - temp file should use unpredictable name")
1342
1343 info, err := os.Lstat(predictableTmp)
1344 require.NoError(t, err)
1345 assert.NotZero(t, info.Mode()&os.ModeSymlink, "predictable .tmp path should still be the original symlink")
1346 })
1347
1348 t.Run("with defaults does not corrupt config", func(t *testing.T) {
1349 tmpDir := t.TempDir()
1350 configFile := filepath.Join(tmpDir, "gordon.toml")
1351 initialConfig := `
1352[server]
1353port = 8088
1354registry_port = 5000
1355registry_domain = "reg.example.com"
1356tls_enabled = true
1357tls_port = 8443
1358data_dir = "/tmp/test-gordon"
1359runtime = "auto"
1360
1361[auth]
1362enabled = true
1363secrets_backend = "pass"
1364username = "testadmin"
1365password_hash = "test/auth/password_hash"
1366token_secret = "test/auth/token_secret"
1367token_expiry = "720h"
1368type = "password"
1369
1370[auto_route]
1371enabled = true
1372
1373[network_isolation]
1374enabled = true
1375network_prefix = "gordon"
1376dns_suffix = ".internal"
1377
1378[backups]
1379enabled = true
1380schedule = "daily"
1381storage_dir = "~/.gordon/backups"
1382
1383[routes]
1384"app.example.com" = "reg.example.com/myapp:latest"
1385
1386[attachments]
1387"app.example.com" = ["postgres:15"]
1388
1389[network_groups]
1390backend = ["app.example.com"]
1391`
1392 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1393 require.NoError(t, err)
1394
1395 v := viper.New()
1396 v.SetConfigFile(configFile)
1397 v.SetDefault("server.port", 8088)
1398 v.SetDefault("server.tls_port", 8443)
1399 v.SetDefault("auth.secrets_backend", "")
1400 v.SetDefault("auto_route.enabled", false)
1401 v.SetDefault("network_isolation.enabled", false)
1402 v.SetDefault("backups.enabled", false)
1403
1404 err = v.ReadInConfig()
1405 require.NoError(t, err)
1406
1407 eventBus := mocks.NewMockEventPublisher(t)
1408 svc := NewService(v, eventBus)
1409 ctx := testContext()
1410
1411 err = svc.Load(ctx)
1412 require.NoError(t, err)
1413
1414 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1415 require.NoError(t, err)
1416
1417 v2 := viper.New()
1418 v2.SetConfigFile(configFile)
1419 err = v2.ReadInConfig()
1420 require.NoError(t, err)
1421
1422 assert.Equal(t, 8088, v2.GetInt("server.port"))
1423 assert.Equal(t, 8443, v2.GetInt("server.tls_port"))
1424 assert.Equal(t, "reg.example.com", v2.GetString("server.registry_domain"))
1425 assert.Equal(t, "auto", v2.GetString("server.runtime"))
1426 assert.Equal(t, "pass", v2.GetString("auth.secrets_backend"))
1427 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1428 assert.Equal(t, "test/auth/password_hash", v2.GetString("auth.password_hash"))
1429 assert.Equal(t, "test/auth/token_secret", v2.GetString("auth.token_secret"))
1430 assert.Equal(t, "password", v2.GetString("auth.type"))
1431 assert.True(t, v2.GetBool("auto_route.enabled"))
1432 assert.True(t, v2.GetBool("network_isolation.enabled"))
1433 assert.Equal(t, ".internal", v2.GetString("network_isolation.dns_suffix"))
1434 assert.True(t, v2.GetBool("backups.enabled"))
1435 assert.Equal(t, "~/.gordon/backups", v2.GetString("backups.storage_dir"))
1436
1437 savedRoutes := loadStringMap(v2.Get("routes"))
1438 assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])
1439 assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])
1440
1441 matches, err := filepath.Glob(configFile + ".bak.*")
1442 require.NoError(t, err)
1443 assert.NotEmpty(t, matches)
1444 })
1445
1446 t.Run("backup created before write", func(t *testing.T) {
1447 tmpDir := t.TempDir()
1448 configFile := filepath.Join(tmpDir, "gordon.toml")
1449 initialConfig := `
1450[server]
1451port = 9999
1452
1453[routes]
1454"app.example.com" = "myapp:latest"
1455`
1456 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1457 require.NoError(t, err)
1458
1459 v := viper.New()
1460 v.SetConfigFile(configFile)
1461 err = v.ReadInConfig()
1462 require.NoError(t, err)
1463
1464 eventBus := mocks.NewMockEventPublisher(t)
1465 svc := NewService(v, eventBus)
1466 ctx := testContext()
1467 err = svc.Load(ctx)
1468 require.NoError(t, err)
1469
1470 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1471 require.NoError(t, err)
1472
1473 matches, err := filepath.Glob(configFile + ".bak.*")
1474 require.NoError(t, err)
1475 require.NotEmpty(t, matches)
1476
1477 backupData, err := os.ReadFile(matches[0])
1478 require.NoError(t, err)
1479 assert.Contains(t, string(backupData), "port = 9999")
1480 assert.NotContains(t, string(backupData), "new.example.com")
1481 })
1482
1483 t.Run("old backups are cleaned up keeping 5", func(t *testing.T) {
1484 tmpDir := t.TempDir()
1485 configFile := filepath.Join(tmpDir, "gordon.toml")
1486 initialConfig := `
1487[server]
1488port = 9999
1489
1490[routes]
1491"app.example.com" = "myapp:latest"
1492`
1493 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1494 require.NoError(t, err)
1495
1496 for i := 1000000000000; i < 1000000000007; i++ {
1497 backupPath := fmt.Sprintf("%s.bak.%d", configFile, i)
1498 err := os.WriteFile(backupPath, []byte("old backup"), 0600)
1499 require.NoError(t, err)
1500 }
1501
1502 v := viper.New()
1503 v.SetConfigFile(configFile)
1504 err = v.ReadInConfig()
1505 require.NoError(t, err)
1506
1507 eventBus := mocks.NewMockEventPublisher(t)
1508 svc := NewService(v, eventBus)
1509 ctx := testContext()
1510 err = svc.Load(ctx)
1511 require.NoError(t, err)
1512
1513 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1514 require.NoError(t, err)
1515
1516 matches, err := filepath.Glob(configFile + ".bak.*")
1517 require.NoError(t, err)
1518 assert.Equal(t, 5, len(matches))
1519 })
1520
1521 t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {
1522 tmpDir := t.TempDir()
1523 configFile := filepath.Join(tmpDir, "gordon.toml")
1524 initialConfig := `
1525[server]
1526port = 8088
1527
1528[auth]
1529username = "testadmin"
1530token_secret = "testsecret123"
1531
1532[routes]
1533"pitlane.example.com" = "pitlane:latest"
1534`
1535 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1536 require.NoError(t, err)
1537
1538 v := viper.New()
1539 v.SetConfigFile(configFile)
1540 err = v.ReadInConfig()
1541 require.NoError(t, err)
1542
1543 eventBus := mocks.NewMockEventPublisher(t)
1544 svc := NewService(v, eventBus)
1545 ctx := testContext()
1546 err = svc.Load(ctx)
1547 require.NoError(t, err)
1548
1549 err = svc.AddRoute(ctx, domain.Route{
1550 Domain: "pitlane.example.com",
1551 Image: "reg.example.com/pitlane:latest",
1552 })
1553 require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route")
1554
1555 routes := svc.GetRoutes(ctx)
1556 require.Len(t, routes, 1)
1557 assert.Equal(t, "pitlane.example.com", routes[0].Domain)
1558 assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image)
1559
1560 v2 := viper.New()
1561 v2.SetConfigFile(configFile)
1562 err = v2.ReadInConfig()
1563 require.NoError(t, err)
1564 assert.Equal(t, 8088, v2.GetInt("server.port"))
1565 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1566 assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1567 })
1568
1569 t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) {
1570 tmpDir := t.TempDir()
1571 configFile := filepath.Join(tmpDir, "gordon.toml")
1572 initialConfig := `
1573[server]
1574port = 8088
1575
1576[auth]
1577username = "testadmin"
1578token_secret = "testsecret123"
1579
1580[routes]
1581"pitlane.example.com" = "reg.example.com/pitlane:latest"
1582"other.example.com" = "reg.example.com/other:latest"
1583`
1584 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1585 require.NoError(t, err)
1586
1587 v := viper.New()
1588 v.SetConfigFile(configFile)
1589 err = v.ReadInConfig()
1590 require.NoError(t, err)
1591
1592 eventBus := mocks.NewMockEventPublisher(t)
1593 svc := NewService(v, eventBus)
1594 ctx := testContext()
1595 err = svc.Load(ctx)
1596 require.NoError(t, err)
1597
1598 err = svc.RemoveRoute(ctx, "pitlane.example.com")
1599 require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route")
1600
1601 routes := svc.GetRoutes(ctx)
1602 require.Len(t, routes, 1)
1603 assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved")
1604 assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved")
1605
1606 v2 := viper.New()
1607 v2.SetConfigFile(configFile)
1608 err = v2.ReadInConfig()
1609 require.NoError(t, err)
1610 assert.Equal(t, 8088, v2.GetInt("server.port"))
1611 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1612 assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1613 })
1614 t.Run("network_groups are persisted to disk", func(t *testing.T) {
1615 // Setup: config file with auth, server, and network_groups
1616 tmpDir := t.TempDir()
1617 configFile := filepath.Join(tmpDir, "gordon.toml")
1618 initialConfig := `
1619[server]
1620port = 9999
1621
1622[auth]
1623username = "admin"
1624token_secret = "supersecretvalue"
1625
1626[routes]
1627"app.example.com" = "myapp:latest"
1628
1629[network_groups]
1630frontend = ["app1.example.com", "app2.example.com"]
1631`
1632 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1633 require.NoError(t, err)
1634
1635 v := viper.New()
1636 v.SetConfigFile(configFile)
1637 err = v.ReadInConfig()
1638 require.NoError(t, err)
1639
1640 eventBus := mocks.NewMockEventPublisher(t)
1641 svc := NewService(v, eventBus)
1642 ctx := testContext()
1643
1644 err = svc.Load(ctx)
1645 require.NoError(t, err)
1646
1647 // Verify network_groups loaded correctly in memory
1648 groups := svc.GetNetworkGroups()
1649 require.Len(t, groups, 1)
1650 require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
1651
1652 // Trigger a Save via AddRoute (which calls Save internally)
1653 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1654 require.NoError(t, err)
1655
1656 // Re-read the file to verify network_groups was persisted
1657 v2 := viper.New()
1658 v2.SetConfigFile(configFile)
1659 err = v2.ReadInConfig()
1660 require.NoError(t, err)
1661
1662 // Verify network_groups is still intact and correct after save
1663 savedGroups := loadStringArrayMap(v2.Get("network_groups"))
1664 assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config")
1665 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"])
1666
1667 // Verify auth fields are preserved
1668 assert.Equal(t, "admin", v2.GetString("auth.username"))
1669 assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret"))
1670
1671 // Verify server fields are preserved
1672 assert.Equal(t, 9999, v2.GetInt("server.port"))
1673 })
1674
1675 // Regression test: external_routes keys contain dots (e.g. "reg.example.com").
1676 // Without explicitly calling viper.Set("external_routes", ...) before WriteConfig,
1677 // viper splits dotted keys into nested subtrees, corrupting the data on re-read.
1678 t.Run("external_routes with dotted domain keys are not corrupted on Save", func(t *testing.T) {
1679 tmpDir := t.TempDir()
1680 configFile := filepath.Join(tmpDir, "gordon.toml")
1681 initialConfig := `
1682[server]
1683port = 8080
1684
1685[routes]
1686"app.example.com" = "myapp:latest"
1687
1688[external_routes]
1689"reg.example.com" = "localhost:5000"
1690`
1691 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1692 require.NoError(t, err)
1693
1694 v := viper.New()
1695 v.SetConfigFile(configFile)
1696 err = v.ReadInConfig()
1697 require.NoError(t, err)
1698
1699 eventBus := mocks.NewMockEventPublisher(t)
1700 svc := NewService(v, eventBus)
1701 ctx := testContext()
1702
1703 err = svc.Load(ctx)
1704 require.NoError(t, err)
1705
1706 // Verify external_routes loaded correctly in memory
1707 extRoutes := svc.GetExternalRoutes()
1708 require.Equal(t, "localhost:5000", extRoutes["reg.example.com"])
1709
1710 // Trigger Save
1711 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1712 require.NoError(t, err)
1713
1714 // Re-read the file with a fresh viper instance
1715 v2 := viper.New()
1716 v2.SetConfigFile(configFile)
1717 err = v2.ReadInConfig()
1718 require.NoError(t, err)
1719
1720 // external_routes must survive Save with correct flat map structure.
1721 // Without the fix, viper splits "reg.example.com" into nested TOML subtrees
1722 // and GetString("external_routes.reg\\.example\\.com") returns empty string.
1723 savedExtRoutes := loadStringMap(v2.Get("external_routes"))
1724 assert.Equal(t, "localhost:5000", savedExtRoutes["reg.example.com"],
1725 "external_routes must not be corrupted by viper's dot-path splitting on WriteConfig")
1726 })
1727
1728 t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) {
1729 // Setup: config file WITHOUT network_groups
1730 tmpDir := t.TempDir()
1731 configFile := filepath.Join(tmpDir, "gordon.toml")
1732 initialConfig := `
1733[server]
1734port = 8080
1735
1736[routes]
1737"app.example.com" = "myapp:latest"
1738`
1739 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1740 require.NoError(t, err)
1741
1742 v := viper.New()
1743 v.SetConfigFile(configFile)
1744 err = v.ReadInConfig()
1745 require.NoError(t, err)
1746
1747 // Inject network_groups into viper as if they were loaded from some other source
1748 // This simulates a case where network_groups exist in memory but not on disk
1749 v.Set("network_groups", map[string]interface{}{
1750 "backend": []interface{}{"api.example.com"},
1751 })
1752
1753 eventBus := mocks.NewMockEventPublisher(t)
1754 svc := NewService(v, eventBus)
1755 ctx := testContext()
1756
1757 err = svc.Load(ctx)
1758 require.NoError(t, err)
1759
1760 // Verify network_groups is loaded in memory
1761 groups := svc.GetNetworkGroups()
1762 require.Len(t, groups, 1)
1763
1764 // Trigger Save
1765 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1766 require.NoError(t, err)
1767
1768 // Re-read the file
1769 v2 := viper.New()
1770 v2.SetConfigFile(configFile)
1771 err = v2.ReadInConfig()
1772 require.NoError(t, err)
1773
1774 // network_groups must be present in the saved file
1775 savedGroups := loadStringArrayMap(v2.Get("network_groups"))
1776 assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save")
1777 assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"])
1778 })
1779}
1780
1781func TestExtractDomainFromImageName(t *testing.T) {
1782 tests := []struct {
1783 name string
1784 imageName string
1785 expectedDom string
1786 expectedFound bool
1787 }{
1788 {
1789 name: "domain like image",
1790 imageName: "myapp.example.com:latest",
1791 expectedDom: "myapp.example.com",
1792 expectedFound: true,
1793 },
1794 {
1795 name: "domain like image without tag",
1796 imageName: "api.backend.io",
1797 expectedDom: "api.backend.io",
1798 expectedFound: true,
1799 },
1800 {
1801 name: "simple image name",
1802 imageName: "nginx:latest",
1803 expectedDom: "",
1804 expectedFound: false,
1805 },
1806 {
1807 name: "registry path",
1808 imageName: "gcr.io/project/image:tag",
1809 expectedDom: "gcr.io/project/image",
1810 expectedFound: true,
1811 },
1812 {
1813 name: "leading dot",
1814 imageName: ".invalid:latest",
1815 expectedDom: "",
1816 expectedFound: false,
1817 },
1818 {
1819 name: "trailing dot",
1820 imageName: "invalid.:latest",
1821 expectedDom: "",
1822 expectedFound: false,
1823 },
1824 }
1825
1826 for _, tt := range tests {
1827 t.Run(tt.name, func(t *testing.T) {
1828 domain, found := ExtractDomainFromImageName(tt.imageName)
1829 assert.Equal(t, tt.expectedDom, domain)
1830 assert.Equal(t, tt.expectedFound, found)
1831 })
1832 }
1833}
1834
1835func TestLoadStringMap(t *testing.T) {
1836 tests := []struct {
1837 name string
1838 input any
1839 expected map[string]string
1840 }{
1841 {
1842 name: "nil input",
1843 input: nil,
1844 expected: map[string]string{},
1845 },
1846 {
1847 name: "valid map",
1848 input: map[string]any{
1849 "key1": "value1",
1850 "key2": "value2",
1851 },
1852 expected: map[string]string{
1853 "key1": "value1",
1854 "key2": "value2",
1855 },
1856 },
1857 {
1858 name: "invalid type",
1859 input: "not a map",
1860 expected: map[string]string{},
1861 },
1862 {
1863 name: "mixed values",
1864 input: map[string]any{
1865 "string": "value",
1866 "int": 123,
1867 },
1868 expected: map[string]string{
1869 "string": "value",
1870 },
1871 },
1872 }
1873
1874 for _, tt := range tests {
1875 t.Run(tt.name, func(t *testing.T) {
1876 result := loadStringMap(tt.input)
1877 assert.Equal(t, tt.expected, result)
1878 })
1879 }
1880}
1881
1882func TestLoadStringArrayMap(t *testing.T) {
1883 tests := []struct {
1884 name string
1885 input any
1886 expected map[string][]string
1887 }{
1888 {
1889 name: "nil input",
1890 input: nil,
1891 expected: map[string][]string{},
1892 },
1893 {
1894 name: "valid map",
1895 input: map[string]any{
1896 "group1": []any{"a", "b"},
1897 "group2": []any{"c", "d", "e"},
1898 },
1899 expected: map[string][]string{
1900 "group1": {"a", "b"},
1901 "group2": {"c", "d", "e"},
1902 },
1903 },
1904 {
1905 name: "invalid type",
1906 input: "not a map",
1907 expected: map[string][]string{},
1908 },
1909 {
1910 name: "non-array value",
1911 input: map[string]any{
1912 "array": []any{"a", "b"},
1913 "string": "not an array",
1914 },
1915 expected: map[string][]string{
1916 "array": {"a", "b"},
1917 },
1918 },
1919 }
1920
1921 for _, tt := range tests {
1922 t.Run(tt.name, func(t *testing.T) {
1923 result := loadStringArrayMap(tt.input)
1924 assert.Equal(t, tt.expected, result)
1925 })
1926 }
1927}