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_GetAttachments(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
478 eventBus := mocks.NewMockEventPublisher(t)
479 svc := NewService(v, eventBus)
480 ctx := testContext()
481
482 _ = svc.Load(ctx)
483
484 attachments := svc.GetAttachments()
485
486 assert.Len(t, attachments, 1)
487 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
488}
489
490func TestService_GetAttachmentConfig(t *testing.T) {
491 v := viper.New()
492 v.Set("attachments", map[string]interface{}{
493 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
494 })
495 v.Set("network_groups", map[string]interface{}{
496 "shared": []interface{}{"app.example.com", "api.example.com"},
497 })
498
499 eventBus := mocks.NewMockEventPublisher(t)
500 svc := NewService(v, eventBus)
501 ctx := testContext()
502
503 _ = svc.Load(ctx)
504
505 snapshot := svc.GetAttachmentConfig()
506
507 assert.Len(t, snapshot.Attachments, 1)
508 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, snapshot.Attachments["app.example.com"])
509 assert.Len(t, snapshot.NetworkGroups, 1)
510 assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, snapshot.NetworkGroups["shared"])
511
512 snapshot.Attachments["app.example.com"][0] = "mutated"
513 snapshot.NetworkGroups["shared"][0] = "mutated.example.com"
514
515 freshSnapshot := svc.GetAttachmentConfig()
516 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, freshSnapshot.Attachments["app.example.com"])
517 assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, freshSnapshot.NetworkGroups["shared"])
518}
519
520func TestService_GetAllAttachments(t *testing.T) {
521 t.Run("returns all attachments", func(t *testing.T) {
522 v := viper.New()
523 v.Set("attachments", map[string]interface{}{
524 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
525 "api.example.com": []interface{}{"rabbitmq:3"},
526 })
527
528 eventBus := mocks.NewMockEventPublisher(t)
529 svc := NewService(v, eventBus)
530 ctx := testContext()
531
532 _ = svc.Load(ctx)
533
534 attachments := svc.GetAllAttachments(ctx)
535
536 assert.Len(t, attachments, 2)
537 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
538 assert.ElementsMatch(t, []string{"rabbitmq:3"}, attachments["api.example.com"])
539 })
540
541 t.Run("returns empty map when no attachments", func(t *testing.T) {
542 v := viper.New()
543 eventBus := mocks.NewMockEventPublisher(t)
544 svc := NewService(v, eventBus)
545 ctx := testContext()
546
547 _ = svc.Load(ctx)
548
549 attachments := svc.GetAllAttachments(ctx)
550
551 assert.Empty(t, attachments)
552 })
553
554 t.Run("returns copy not reference", func(t *testing.T) {
555 v := viper.New()
556 v.Set("attachments", map[string]interface{}{
557 "app.example.com": []interface{}{"redis:latest"},
558 })
559
560 eventBus := mocks.NewMockEventPublisher(t)
561 svc := NewService(v, eventBus)
562 ctx := testContext()
563
564 _ = svc.Load(ctx)
565
566 attachments := svc.GetAllAttachments(ctx)
567 // Modify the returned map
568 attachments["app.example.com"] = append(attachments["app.example.com"], "postgres:18")
569
570 // Original should be unchanged
571 original := svc.GetAllAttachments(ctx)
572 assert.Len(t, original["app.example.com"], 1)
573 })
574}
575
576func TestService_GetAttachmentsFor(t *testing.T) {
577 t.Run("existing domain", func(t *testing.T) {
578 v := viper.New()
579 v.Set("attachments", map[string]interface{}{
580 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
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, "app.example.com")
590
591 require.NoError(t, err)
592 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, images)
593 })
594
595 t.Run("existing network group", func(t *testing.T) {
596 v := viper.New()
597 v.Set("attachments", map[string]interface{}{
598 "backend": []interface{}{"rabbitmq:3", "redis:latest"},
599 })
600
601 eventBus := mocks.NewMockEventPublisher(t)
602 svc := NewService(v, eventBus)
603 ctx := testContext()
604
605 _ = svc.Load(ctx)
606
607 images, err := svc.GetAttachmentsFor(ctx, "backend")
608
609 require.NoError(t, err)
610 assert.ElementsMatch(t, []string{"rabbitmq:3", "redis:latest"}, images)
611 })
612
613 t.Run("non-existent target", func(t *testing.T) {
614 v := viper.New()
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, "notfound.example.com")
622
623 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
624 assert.Nil(t, images)
625 })
626
627 t.Run("returns copy not reference", func(t *testing.T) {
628 v := viper.New()
629 v.Set("attachments", map[string]interface{}{
630 "app.example.com": []interface{}{"redis:latest"},
631 })
632
633 eventBus := mocks.NewMockEventPublisher(t)
634 svc := NewService(v, eventBus)
635 ctx := testContext()
636
637 _ = svc.Load(ctx)
638
639 images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
640 require.NoError(t, err)
641
642 // Modify the returned slice (use _ to satisfy linter)
643 _ = append(images, "postgres:18")
644
645 // Original should be unchanged
646 original, _ := svc.GetAttachmentsFor(ctx, "app.example.com")
647 assert.Len(t, original, 1)
648 })
649}
650
651func TestService_AddAttachment(t *testing.T) {
652 t.Run("success - new target", func(t *testing.T) {
653 tmpDir := t.TempDir()
654 configFile := filepath.Join(tmpDir, "gordon.toml")
655 err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
656 require.NoError(t, err)
657
658 v := viper.New()
659 v.SetConfigFile(configFile)
660 eventBus := mocks.NewMockEventPublisher(t)
661 svc := NewService(v, eventBus)
662 ctx := testContext()
663
664 _ = svc.Load(ctx)
665
666 err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
667
668 assert.NoError(t, err)
669
670 // Verify attachment was added
671 config := svc.GetConfig()
672 assert.Contains(t, config.Attachments["app.example.com"], "postgres:18")
673 })
674
675 t.Run("success - existing target", func(t *testing.T) {
676 tmpDir := t.TempDir()
677 configFile := filepath.Join(tmpDir, "gordon.toml")
678 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
679 require.NoError(t, err)
680
681 v := viper.New()
682 v.SetConfigFile(configFile)
683 v.Set("attachments", map[string]interface{}{
684 "app.example.com": []interface{}{"redis:latest"},
685 })
686 eventBus := mocks.NewMockEventPublisher(t)
687 svc := NewService(v, eventBus)
688 ctx := testContext()
689
690 _ = svc.Load(ctx)
691
692 err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
693
694 assert.NoError(t, err)
695
696 // Verify attachment was added alongside existing
697 config := svc.GetConfig()
698 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
699 })
700
701 t.Run("duplicate attachment", func(t *testing.T) {
702 v := viper.New()
703 v.Set("attachments", map[string]interface{}{
704 "app.example.com": []interface{}{"redis:latest"},
705 })
706 eventBus := mocks.NewMockEventPublisher(t)
707 svc := NewService(v, eventBus)
708 ctx := testContext()
709
710 _ = svc.Load(ctx)
711
712 err := svc.AddAttachment(ctx, "app.example.com", "redis:latest")
713
714 assert.ErrorIs(t, err, domain.ErrAttachmentExists)
715 })
716
717 t.Run("empty target", func(t *testing.T) {
718 v := viper.New()
719 eventBus := mocks.NewMockEventPublisher(t)
720 svc := NewService(v, eventBus)
721 ctx := testContext()
722
723 err := svc.AddAttachment(ctx, "", "postgres:18")
724
725 assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
726 })
727
728 t.Run("empty image", func(t *testing.T) {
729 v := viper.New()
730 eventBus := mocks.NewMockEventPublisher(t)
731 svc := NewService(v, eventBus)
732 ctx := testContext()
733
734 err := svc.AddAttachment(ctx, "app.example.com", "")
735
736 assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
737 })
738
739 t.Run("network group target", func(t *testing.T) {
740 tmpDir := t.TempDir()
741 configFile := filepath.Join(tmpDir, "gordon.toml")
742 err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
743 require.NoError(t, err)
744
745 v := viper.New()
746 v.SetConfigFile(configFile)
747 eventBus := mocks.NewMockEventPublisher(t)
748 svc := NewService(v, eventBus)
749 ctx := testContext()
750
751 _ = svc.Load(ctx)
752
753 err = svc.AddAttachment(ctx, "backend", "rabbitmq:3")
754
755 assert.NoError(t, err)
756
757 config := svc.GetConfig()
758 assert.Contains(t, config.Attachments["backend"], "rabbitmq:3")
759 })
760}
761
762func TestService_RemoveAttachment(t *testing.T) {
763 t.Run("success - single attachment", func(t *testing.T) {
764 tmpDir := t.TempDir()
765 configFile := filepath.Join(tmpDir, "gordon.toml")
766 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
767 require.NoError(t, err)
768
769 v := viper.New()
770 v.SetConfigFile(configFile)
771 v.Set("attachments", map[string]interface{}{
772 "app.example.com": []interface{}{"redis:latest"},
773 })
774 eventBus := mocks.NewMockEventPublisher(t)
775 svc := NewService(v, eventBus)
776 ctx := testContext()
777
778 _ = svc.Load(ctx)
779
780 err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
781
782 assert.NoError(t, err)
783
784 // Verify target was removed (no attachments left)
785 config := svc.GetConfig()
786 _, exists := config.Attachments["app.example.com"]
787 assert.False(t, exists)
788 })
789
790 t.Run("success - multiple attachments", func(t *testing.T) {
791 tmpDir := t.TempDir()
792 configFile := filepath.Join(tmpDir, "gordon.toml")
793 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\", \"postgres:18\"]\n"), 0600)
794 require.NoError(t, err)
795
796 v := viper.New()
797 v.SetConfigFile(configFile)
798 v.Set("attachments", map[string]interface{}{
799 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
800 })
801 eventBus := mocks.NewMockEventPublisher(t)
802 svc := NewService(v, eventBus)
803 ctx := testContext()
804
805 _ = svc.Load(ctx)
806
807 err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
808
809 assert.NoError(t, err)
810
811 // Verify only redis was removed
812 config := svc.GetConfig()
813 assert.Equal(t, []string{"postgres:18"}, config.Attachments["app.example.com"])
814 })
815
816 t.Run("target not found", func(t *testing.T) {
817 v := viper.New()
818 eventBus := mocks.NewMockEventPublisher(t)
819 svc := NewService(v, eventBus)
820 ctx := testContext()
821
822 _ = svc.Load(ctx)
823
824 err := svc.RemoveAttachment(ctx, "notfound.example.com", "redis:latest")
825
826 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
827 })
828
829 t.Run("image not found in target", func(t *testing.T) {
830 v := viper.New()
831 v.Set("attachments", map[string]interface{}{
832 "app.example.com": []interface{}{"redis:latest"},
833 })
834 eventBus := mocks.NewMockEventPublisher(t)
835 svc := NewService(v, eventBus)
836 ctx := testContext()
837
838 _ = svc.Load(ctx)
839
840 err := svc.RemoveAttachment(ctx, "app.example.com", "postgres:18")
841
842 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
843 })
844
845 t.Run("empty target", func(t *testing.T) {
846 v := viper.New()
847 eventBus := mocks.NewMockEventPublisher(t)
848 svc := NewService(v, eventBus)
849 ctx := testContext()
850
851 err := svc.RemoveAttachment(ctx, "", "redis:latest")
852
853 assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
854 })
855
856 t.Run("empty image", func(t *testing.T) {
857 v := viper.New()
858 eventBus := mocks.NewMockEventPublisher(t)
859 svc := NewService(v, eventBus)
860 ctx := testContext()
861
862 err := svc.RemoveAttachment(ctx, "app.example.com", "")
863
864 assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
865 })
866
867 t.Run("network group target", func(t *testing.T) {
868 tmpDir := t.TempDir()
869 configFile := filepath.Join(tmpDir, "gordon.toml")
870 err := os.WriteFile(configFile, []byte("[attachments]\n\"backend\" = [\"rabbitmq:3\"]\n"), 0600)
871 require.NoError(t, err)
872
873 v := viper.New()
874 v.SetConfigFile(configFile)
875 v.Set("attachments", map[string]interface{}{
876 "backend": []interface{}{"rabbitmq:3"},
877 })
878 eventBus := mocks.NewMockEventPublisher(t)
879 svc := NewService(v, eventBus)
880 ctx := testContext()
881
882 _ = svc.Load(ctx)
883
884 err = svc.RemoveAttachment(ctx, "backend", "rabbitmq:3")
885
886 assert.NoError(t, err)
887
888 config := svc.GetConfig()
889 _, exists := config.Attachments["backend"]
890 assert.False(t, exists)
891 })
892}
893
894func TestService_AutoRouteAllowedDomains(t *testing.T) {
895 t.Run("get returns copy", func(t *testing.T) {
896 v := viper.New()
897 v.Set("auto_route_allowed_domains", []string{"example.com", "*.example.com"})
898
899 eventBus := mocks.NewMockEventPublisher(t)
900 svc := NewService(v, eventBus)
901 ctx := testContext()
902
903 require.NoError(t, svc.Load(ctx))
904
905 domains, err := svc.GetAutoRouteAllowedDomains(ctx)
906 require.NoError(t, err)
907 domains[0] = "changed.com"
908
909 again, err := svc.GetAutoRouteAllowedDomains(ctx)
910 require.NoError(t, err)
911 assert.Equal(t, []string{"example.com", "*.example.com"}, again)
912 })
913
914 t.Run("add and remove persist idempotently", func(t *testing.T) {
915 tmpDir := t.TempDir()
916 configFile := filepath.Join(tmpDir, "gordon.toml")
917 require.NoError(t, os.WriteFile(configFile, []byte("auto_route_allowed_domains = [\"example.com\"]\n"), 0600))
918
919 v := viper.New()
920 v.SetConfigFile(configFile)
921 v.Set("auto_route_allowed_domains", []interface{}{"example.com"})
922
923 eventBus := mocks.NewMockEventPublisher(t)
924 svc := NewService(v, eventBus)
925 ctx := testContext()
926
927 require.NoError(t, svc.Load(ctx))
928 require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
929 require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
930
931 domains, err := svc.GetAutoRouteAllowedDomains(ctx)
932 require.NoError(t, err)
933 assert.Equal(t, []string{"example.com", "*.example.com"}, domains)
934
935 require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "example.com"))
936 require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "missing.com"))
937
938 domains, err = svc.GetAutoRouteAllowedDomains(ctx)
939 require.NoError(t, err)
940 assert.Equal(t, []string{"*.example.com"}, domains)
941 })
942
943 t.Run("add validates patterns", func(t *testing.T) {
944 v := viper.New()
945 eventBus := mocks.NewMockEventPublisher(t)
946 svc := NewService(v, eventBus)
947 ctx := testContext()
948 require.NoError(t, svc.Load(ctx))
949
950 invalid := []string{"", "EXAMPLE.com", "example.com.", "**.example.com", "foo.*.example.com", "*.example.com."}
951 for _, pattern := range invalid {
952 err := svc.AddAutoRouteAllowedDomain(ctx, pattern)
953 assert.ErrorIs(t, err, domain.ErrInvalidDomainPattern, "pattern: %s", pattern)
954 }
955 })
956}
957
958func TestService_GetExternalRoutes(t *testing.T) {
959 v := viper.New()
960 v.Set("external_routes", map[string]interface{}{
961 "reg.example.com": "localhost:5000",
962 "cache.example.com": "127.0.0.1:6379",
963 })
964
965 eventBus := mocks.NewMockEventPublisher(t)
966 svc := NewService(v, eventBus)
967 ctx := testContext()
968
969 _ = svc.Load(ctx)
970
971 routes := svc.GetExternalRoutes()
972
973 assert.Len(t, routes, 2)
974 assert.Equal(t, "localhost:5000", routes["reg.example.com"])
975 assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
976}
977
978func TestService_GetExternalRoutes_Empty(t *testing.T) {
979 v := viper.New()
980
981 eventBus := mocks.NewMockEventPublisher(t)
982 svc := NewService(v, eventBus)
983 ctx := testContext()
984
985 _ = svc.Load(ctx)
986
987 routes := svc.GetExternalRoutes()
988
989 assert.Empty(t, routes)
990}
991
992func TestSplitImageNameTag(t *testing.T) {
993 tests := []struct {
994 name string
995 image string
996 wantName string
997 wantHasTag bool
998 }{
999 {"with tag", "myapp:latest", "myapp", true},
1000 {"with version tag", "myapp:v1.2.0", "myapp", true},
1001 {"no tag", "myapp", "myapp", false},
1002 {"empty string", "", "", false},
1003 {"tag only colon", "myapp:", "myapp", true},
1004 {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true},
1005 {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false},
1006 }
1007
1008 for _, tt := range tests {
1009 t.Run(tt.name, func(t *testing.T) {
1010 name, hasTag := splitImageNameTag(tt.image)
1011 assert.Equal(t, tt.wantName, name)
1012 assert.Equal(t, tt.wantHasTag, hasTag)
1013 })
1014 }
1015}
1016
1017func TestNormalizeRegistryImage(t *testing.T) {
1018 tests := []struct {
1019 name string
1020 imageName string
1021 registryDomain string
1022 expected string
1023 }{
1024 {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
1025 {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
1026 {"empty registry domain", "myapp:latest", "", "myapp:latest"},
1027 {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
1028 {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
1029 {"bare name no registry", "myapp", "reg.example.com", "myapp"},
1030 {"exact registry without image", "reg.example.com/", "reg.example.com", ""},
1031 }
1032
1033 for _, tt := range tests {
1034 t.Run(tt.name, func(t *testing.T) {
1035 result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
1036 assert.Equal(t, tt.expected, result)
1037 })
1038 }
1039}
1040
1041func TestNormalizeBootstrapImage(t *testing.T) {
1042 tests := []struct {
1043 name string
1044 image string
1045 registryDomain string
1046 expected string
1047 wantErr string
1048 }{
1049 {name: "bare name prepends registry", image: "myapp", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1050 {name: "bare name strips tag", image: "myapp:v1", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1051 {name: "qualified image unchanged", image: "reg.example.com/myapp", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1052 {name: "qualified image strips tag", image: "reg.example.com/myapp:abc123", registryDomain: "reg.example.com", expected: "reg.example.com/myapp"},
1053 {name: "other registry strips tag", image: "other.registry.com/myapp:v2", registryDomain: "reg.example.com", expected: "other.registry.com/myapp"},
1054 {name: "empty image", image: "", registryDomain: "reg.example.com", wantErr: "image is required"},
1055 {name: "empty registry domain", image: "pitlane", registryDomain: "", wantErr: "registry domain is not configured"},
1056 }
1057
1058 for _, tt := range tests {
1059 t.Run(tt.name, func(t *testing.T) {
1060 result, err := NormalizeBootstrapImage(tt.image, tt.registryDomain)
1061 if tt.wantErr != "" {
1062 require.Error(t, err)
1063 assert.Equal(t, tt.wantErr, err.Error())
1064 assert.Empty(t, result)
1065 return
1066 }
1067
1068 require.NoError(t, err)
1069 assert.Equal(t, tt.expected, result)
1070 })
1071 }
1072}
1073
1074func TestService_FindRoutesByImage(t *testing.T) {
1075 t.Run("exact match with tag", func(t *testing.T) {
1076 v := viper.New()
1077 v.Set("routes", map[string]interface{}{
1078 "app.example.com": "myapp:latest",
1079 })
1080
1081 eventBus := mocks.NewMockEventPublisher(t)
1082 svc := NewService(v, eventBus)
1083 ctx := testContext()
1084 _ = svc.Load(ctx)
1085
1086 routes := svc.FindRoutesByImage(ctx, "myapp:latest")
1087 assert.Len(t, routes, 1)
1088 assert.Equal(t, "app.example.com", routes[0].Domain)
1089 assert.Equal(t, "myapp:latest", routes[0].Image)
1090 })
1091
1092 t.Run("bare name matches route with tag", func(t *testing.T) {
1093 v := viper.New()
1094 v.Set("routes", map[string]interface{}{
1095 "app.example.com": "myapp:latest",
1096 })
1097
1098 eventBus := mocks.NewMockEventPublisher(t)
1099 svc := NewService(v, eventBus)
1100 ctx := testContext()
1101 _ = svc.Load(ctx)
1102
1103 routes := svc.FindRoutesByImage(ctx, "myapp")
1104 assert.Len(t, routes, 1)
1105 assert.Equal(t, "app.example.com", routes[0].Domain)
1106 })
1107
1108 t.Run("bare name matches route with version tag", func(t *testing.T) {
1109 v := viper.New()
1110 v.Set("routes", map[string]interface{}{
1111 "app.example.com": "myapp:v2.0.0",
1112 })
1113
1114 eventBus := mocks.NewMockEventPublisher(t)
1115 svc := NewService(v, eventBus)
1116 ctx := testContext()
1117 _ = svc.Load(ctx)
1118
1119 routes := svc.FindRoutesByImage(ctx, "myapp")
1120 assert.Len(t, routes, 1)
1121 assert.Equal(t, "app.example.com", routes[0].Domain)
1122 })
1123
1124 t.Run("tag mismatch does not match", func(t *testing.T) {
1125 v := viper.New()
1126 v.Set("routes", map[string]interface{}{
1127 "app.example.com": "myapp:v1",
1128 })
1129
1130 eventBus := mocks.NewMockEventPublisher(t)
1131 svc := NewService(v, eventBus)
1132 ctx := testContext()
1133 _ = svc.Load(ctx)
1134
1135 routes := svc.FindRoutesByImage(ctx, "myapp:v2")
1136 assert.Empty(t, routes)
1137 })
1138
1139 t.Run("no match", func(t *testing.T) {
1140 v := viper.New()
1141 v.Set("routes", map[string]interface{}{
1142 "app.example.com": "myapp:latest",
1143 })
1144
1145 eventBus := mocks.NewMockEventPublisher(t)
1146 svc := NewService(v, eventBus)
1147 ctx := testContext()
1148 _ = svc.Load(ctx)
1149
1150 routes := svc.FindRoutesByImage(ctx, "otherapp")
1151 assert.Empty(t, routes)
1152 })
1153
1154 t.Run("strips registry prefix before matching", func(t *testing.T) {
1155 v := viper.New()
1156 v.Set("server.registry_domain", "reg.example.com")
1157 v.Set("routes", map[string]interface{}{
1158 "app.example.com": "reg.example.com/myapp:latest",
1159 })
1160
1161 eventBus := mocks.NewMockEventPublisher(t)
1162 svc := NewService(v, eventBus)
1163 ctx := testContext()
1164 _ = svc.Load(ctx)
1165
1166 routes := svc.FindRoutesByImage(ctx, "myapp")
1167 assert.Len(t, routes, 1)
1168 assert.Equal(t, "app.example.com", routes[0].Domain)
1169 })
1170
1171 t.Run("strips registry prefix from input too", func(t *testing.T) {
1172 v := viper.New()
1173 v.Set("server.registry_domain", "reg.example.com")
1174 v.Set("routes", map[string]interface{}{
1175 "app.example.com": "reg.example.com/myapp: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, "reg.example.com/myapp:latest")
1184 assert.Len(t, routes, 1)
1185 assert.Equal(t, "app.example.com", routes[0].Domain)
1186 })
1187
1188 t.Run("multiple routes for same image", func(t *testing.T) {
1189 v := viper.New()
1190 v.Set("routes", map[string]interface{}{
1191 "app1.example.com": "myapp:latest",
1192 "app2.example.com": "myapp:latest",
1193 "other.example.com": "otherapp: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, 2)
1203
1204 domains := []string{routes[0].Domain, routes[1].Domain}
1205 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains)
1206 })
1207
1208 t.Run("case insensitive match", func(t *testing.T) {
1209 v := viper.New()
1210 v.Set("routes", map[string]interface{}{
1211 "app.example.com": "MyApp:latest",
1212 })
1213
1214 eventBus := mocks.NewMockEventPublisher(t)
1215 svc := NewService(v, eventBus)
1216 ctx := testContext()
1217 _ = svc.Load(ctx)
1218
1219 routes := svc.FindRoutesByImage(ctx, "myapp")
1220 assert.Len(t, routes, 1)
1221 })
1222
1223 t.Run("http prefix route sets HTTPS false", func(t *testing.T) {
1224 v := viper.New()
1225 v.Set("routes", map[string]interface{}{
1226 "http://insecure.example.com": "myapp:latest",
1227 })
1228
1229 eventBus := mocks.NewMockEventPublisher(t)
1230 svc := NewService(v, eventBus)
1231 ctx := testContext()
1232 _ = svc.Load(ctx)
1233
1234 routes := svc.FindRoutesByImage(ctx, "myapp")
1235 assert.Len(t, routes, 1)
1236 assert.Equal(t, "insecure.example.com", routes[0].Domain)
1237 assert.False(t, routes[0].HTTPS)
1238 })
1239
1240 t.Run("empty routes", func(t *testing.T) {
1241 v := viper.New()
1242 eventBus := mocks.NewMockEventPublisher(t)
1243 svc := NewService(v, eventBus)
1244 ctx := testContext()
1245 _ = svc.Load(ctx)
1246
1247 routes := svc.FindRoutesByImage(ctx, "myapp")
1248 assert.Empty(t, routes)
1249 })
1250}
1251
1252func TestService_FindAttachmentTargetsByImage(t *testing.T) {
1253 t.Run("exact tagged matches", func(t *testing.T) {
1254 v := viper.New()
1255 v.Set("attachments", map[string]interface{}{
1256 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
1257 })
1258
1259 eventBus := mocks.NewMockEventPublisher(t)
1260 svc := NewService(v, eventBus)
1261 ctx := testContext()
1262 _ = svc.Load(ctx)
1263
1264 targets := svc.FindAttachmentTargetsByImage(ctx, "postgres:18")
1265 assert.Equal(t, []string{"app.example.com"}, targets)
1266 })
1267
1268 t.Run("bare name matches tagged attachments", func(t *testing.T) {
1269 v := viper.New()
1270 v.Set("attachments", map[string]interface{}{
1271 "app.example.com": []interface{}{"postgres:18"},
1272 })
1273
1274 eventBus := mocks.NewMockEventPublisher(t)
1275 svc := NewService(v, eventBus)
1276 ctx := testContext()
1277 _ = svc.Load(ctx)
1278
1279 targets := svc.FindAttachmentTargetsByImage(ctx, "postgres")
1280 assert.Equal(t, []string{"app.example.com"}, targets)
1281 })
1282
1283 t.Run("registry qualified input is normalized", func(t *testing.T) {
1284 v := viper.New()
1285 v.Set("server.registry_domain", "reg.example.com")
1286 v.Set("attachments", map[string]interface{}{
1287 "backend": []interface{}{"reg.example.com/postgres:18"},
1288 })
1289
1290 eventBus := mocks.NewMockEventPublisher(t)
1291 svc := NewService(v, eventBus)
1292 ctx := testContext()
1293 _ = svc.Load(ctx)
1294
1295 targets := svc.FindAttachmentTargetsByImage(ctx, "reg.example.com/postgres:18")
1296 assert.Equal(t, []string{"backend"}, targets)
1297 })
1298
1299 t.Run("shared attachment used by multiple targets", func(t *testing.T) {
1300 v := viper.New()
1301 v.Set("attachments", map[string]interface{}{
1302 "app.example.com": []interface{}{"redis:latest"},
1303 "backend": []interface{}{"redis:latest", "postgres:18"},
1304 "worker": []interface{}{"rabbitmq:3"},
1305 })
1306
1307 eventBus := mocks.NewMockEventPublisher(t)
1308 svc := NewService(v, eventBus)
1309 ctx := testContext()
1310 _ = svc.Load(ctx)
1311
1312 targets := svc.FindAttachmentTargetsByImage(ctx, "redis")
1313 assert.ElementsMatch(t, []string{"app.example.com", "backend"}, targets)
1314 })
1315
1316 t.Run("no matches", func(t *testing.T) {
1317 v := viper.New()
1318 v.Set("attachments", map[string]interface{}{
1319 "app.example.com": []interface{}{"redis:latest"},
1320 })
1321
1322 eventBus := mocks.NewMockEventPublisher(t)
1323 svc := NewService(v, eventBus)
1324 ctx := testContext()
1325 _ = svc.Load(ctx)
1326
1327 targets := svc.FindAttachmentTargetsByImage(ctx, "postgres")
1328 assert.Empty(t, targets)
1329 })
1330}
1331
1332func TestSavePreservesAllConfigFields(t *testing.T) {
1333 t.Run("does not write predictable tmp path", func(t *testing.T) {
1334 dir := t.TempDir()
1335 configFile := filepath.Join(dir, "gordon.toml")
1336 err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n\n[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
1337 require.NoError(t, err)
1338
1339 v := viper.New()
1340 v.SetConfigFile(configFile)
1341 err = v.ReadInConfig()
1342 require.NoError(t, err)
1343
1344 eventBus := mocks.NewMockEventPublisher(t)
1345 svc := NewService(v, eventBus)
1346 ctx := testContext()
1347 err = svc.Load(ctx)
1348 require.NoError(t, err)
1349
1350 maliciousTarget := filepath.Join(dir, "pwned.txt")
1351 predictableTmp := configFile + ".tmp"
1352 err = os.Symlink(maliciousTarget, predictableTmp)
1353 require.NoError(t, err)
1354
1355 err = svc.Save(ctx)
1356 require.NoError(t, err)
1357
1358 _, statErr := os.Stat(maliciousTarget)
1359 assert.True(t, os.IsNotExist(statErr), "symlink target should not exist - temp file should use unpredictable name")
1360
1361 info, err := os.Lstat(predictableTmp)
1362 require.NoError(t, err)
1363 assert.NotZero(t, info.Mode()&os.ModeSymlink, "predictable .tmp path should still be the original symlink")
1364 })
1365
1366 t.Run("with defaults does not corrupt config", func(t *testing.T) {
1367 tmpDir := t.TempDir()
1368 configFile := filepath.Join(tmpDir, "gordon.toml")
1369 initialConfig := `
1370[server]
1371port = 8088
1372registry_port = 5000
1373registry_domain = "reg.example.com"
1374tls_enabled = true
1375tls_port = 8443
1376data_dir = "/tmp/test-gordon"
1377runtime = "auto"
1378
1379[auth]
1380enabled = true
1381secrets_backend = "pass"
1382username = "testadmin"
1383password_hash = "test/auth/password_hash"
1384token_secret = "test/auth/token_secret"
1385token_expiry = "720h"
1386type = "password"
1387
1388[auto_route]
1389enabled = true
1390
1391[network_isolation]
1392enabled = true
1393network_prefix = "gordon"
1394dns_suffix = ".internal"
1395
1396[backups]
1397enabled = true
1398schedule = "daily"
1399storage_dir = "~/.gordon/backups"
1400
1401[routes]
1402"app.example.com" = "reg.example.com/myapp:latest"
1403
1404[attachments]
1405"app.example.com" = ["postgres:15"]
1406
1407[network_groups]
1408backend = ["app.example.com"]
1409`
1410 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1411 require.NoError(t, err)
1412
1413 v := viper.New()
1414 v.SetConfigFile(configFile)
1415 v.SetDefault("server.port", 80)
1416 v.SetDefault("server.tls_port", 443)
1417 v.SetDefault("auth.secrets_backend", "")
1418 v.SetDefault("auto_route.enabled", false)
1419 v.SetDefault("network_isolation.enabled", false)
1420 v.SetDefault("backups.enabled", false)
1421
1422 err = v.ReadInConfig()
1423 require.NoError(t, err)
1424
1425 eventBus := mocks.NewMockEventPublisher(t)
1426 svc := NewService(v, eventBus)
1427 ctx := testContext()
1428
1429 err = svc.Load(ctx)
1430 require.NoError(t, err)
1431
1432 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1433 require.NoError(t, err)
1434
1435 v2 := viper.New()
1436 v2.SetConfigFile(configFile)
1437 err = v2.ReadInConfig()
1438 require.NoError(t, err)
1439
1440 assert.Equal(t, 8088, v2.GetInt("server.port"))
1441 assert.Equal(t, 8443, v2.GetInt("server.tls_port"))
1442 assert.Equal(t, "reg.example.com", v2.GetString("server.registry_domain"))
1443 assert.Equal(t, "auto", v2.GetString("server.runtime"))
1444 assert.Equal(t, "pass", v2.GetString("auth.secrets_backend"))
1445 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1446 assert.Equal(t, "test/auth/password_hash", v2.GetString("auth.password_hash"))
1447 assert.Equal(t, "test/auth/token_secret", v2.GetString("auth.token_secret"))
1448 assert.Equal(t, "password", v2.GetString("auth.type"))
1449 assert.True(t, v2.GetBool("auto_route.enabled"))
1450 assert.True(t, v2.GetBool("network_isolation.enabled"))
1451 assert.Equal(t, ".internal", v2.GetString("network_isolation.dns_suffix"))
1452 assert.True(t, v2.GetBool("backups.enabled"))
1453 assert.Equal(t, "~/.gordon/backups", v2.GetString("backups.storage_dir"))
1454
1455 savedRoutes := loadStringMap(v2.Get("routes"))
1456 assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])
1457 assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])
1458
1459 matches, err := filepath.Glob(configFile + ".bak.*")
1460 require.NoError(t, err)
1461 assert.NotEmpty(t, matches)
1462 })
1463
1464 t.Run("backup created before write", func(t *testing.T) {
1465 tmpDir := t.TempDir()
1466 configFile := filepath.Join(tmpDir, "gordon.toml")
1467 initialConfig := `
1468[server]
1469port = 9999
1470
1471[routes]
1472"app.example.com" = "myapp:latest"
1473`
1474 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1475 require.NoError(t, err)
1476
1477 v := viper.New()
1478 v.SetConfigFile(configFile)
1479 err = v.ReadInConfig()
1480 require.NoError(t, err)
1481
1482 eventBus := mocks.NewMockEventPublisher(t)
1483 svc := NewService(v, eventBus)
1484 ctx := testContext()
1485 err = svc.Load(ctx)
1486 require.NoError(t, err)
1487
1488 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1489 require.NoError(t, err)
1490
1491 matches, err := filepath.Glob(configFile + ".bak.*")
1492 require.NoError(t, err)
1493 require.NotEmpty(t, matches)
1494
1495 backupData, err := os.ReadFile(matches[0])
1496 require.NoError(t, err)
1497 assert.Contains(t, string(backupData), "port = 9999")
1498 assert.NotContains(t, string(backupData), "new.example.com")
1499 })
1500
1501 t.Run("old backups are cleaned up keeping 5", func(t *testing.T) {
1502 tmpDir := t.TempDir()
1503 configFile := filepath.Join(tmpDir, "gordon.toml")
1504 initialConfig := `
1505[server]
1506port = 9999
1507
1508[routes]
1509"app.example.com" = "myapp:latest"
1510`
1511 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1512 require.NoError(t, err)
1513
1514 for i := 1000000000000; i < 1000000000007; i++ {
1515 backupPath := fmt.Sprintf("%s.bak.%d", configFile, i)
1516 err := os.WriteFile(backupPath, []byte("old backup"), 0600)
1517 require.NoError(t, err)
1518 }
1519
1520 v := viper.New()
1521 v.SetConfigFile(configFile)
1522 err = v.ReadInConfig()
1523 require.NoError(t, err)
1524
1525 eventBus := mocks.NewMockEventPublisher(t)
1526 svc := NewService(v, eventBus)
1527 ctx := testContext()
1528 err = svc.Load(ctx)
1529 require.NoError(t, err)
1530
1531 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1532 require.NoError(t, err)
1533
1534 matches, err := filepath.Glob(configFile + ".bak.*")
1535 require.NoError(t, err)
1536 assert.Equal(t, 5, len(matches))
1537 })
1538
1539 t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {
1540 tmpDir := t.TempDir()
1541 configFile := filepath.Join(tmpDir, "gordon.toml")
1542 initialConfig := `
1543[server]
1544port = 8088
1545
1546[auth]
1547username = "testadmin"
1548token_secret = "testsecret123"
1549
1550[routes]
1551"pitlane.example.com" = "pitlane:latest"
1552`
1553 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1554 require.NoError(t, err)
1555
1556 v := viper.New()
1557 v.SetConfigFile(configFile)
1558 err = v.ReadInConfig()
1559 require.NoError(t, err)
1560
1561 eventBus := mocks.NewMockEventPublisher(t)
1562 svc := NewService(v, eventBus)
1563 ctx := testContext()
1564 err = svc.Load(ctx)
1565 require.NoError(t, err)
1566
1567 err = svc.AddRoute(ctx, domain.Route{
1568 Domain: "pitlane.example.com",
1569 Image: "reg.example.com/pitlane:latest",
1570 })
1571 require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route")
1572
1573 routes := svc.GetRoutes(ctx)
1574 require.Len(t, routes, 1)
1575 assert.Equal(t, "pitlane.example.com", routes[0].Domain)
1576 assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image)
1577
1578 v2 := viper.New()
1579 v2.SetConfigFile(configFile)
1580 err = v2.ReadInConfig()
1581 require.NoError(t, err)
1582 assert.Equal(t, 8088, v2.GetInt("server.port"))
1583 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1584 assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1585 })
1586
1587 t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) {
1588 tmpDir := t.TempDir()
1589 configFile := filepath.Join(tmpDir, "gordon.toml")
1590 initialConfig := `
1591[server]
1592port = 8088
1593
1594[auth]
1595username = "testadmin"
1596token_secret = "testsecret123"
1597
1598[routes]
1599"pitlane.example.com" = "reg.example.com/pitlane:latest"
1600"other.example.com" = "reg.example.com/other:latest"
1601`
1602 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1603 require.NoError(t, err)
1604
1605 v := viper.New()
1606 v.SetConfigFile(configFile)
1607 err = v.ReadInConfig()
1608 require.NoError(t, err)
1609
1610 eventBus := mocks.NewMockEventPublisher(t)
1611 svc := NewService(v, eventBus)
1612 ctx := testContext()
1613 err = svc.Load(ctx)
1614 require.NoError(t, err)
1615
1616 err = svc.RemoveRoute(ctx, "pitlane.example.com")
1617 require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route")
1618
1619 routes := svc.GetRoutes(ctx)
1620 require.Len(t, routes, 1)
1621 assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved")
1622 assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved")
1623
1624 v2 := viper.New()
1625 v2.SetConfigFile(configFile)
1626 err = v2.ReadInConfig()
1627 require.NoError(t, err)
1628 assert.Equal(t, 8088, v2.GetInt("server.port"))
1629 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1630 assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1631 })
1632 t.Run("network_groups are persisted to disk", func(t *testing.T) {
1633 // Setup: config file with auth, server, and network_groups
1634 tmpDir := t.TempDir()
1635 configFile := filepath.Join(tmpDir, "gordon.toml")
1636 initialConfig := `
1637[server]
1638port = 9999
1639
1640[auth]
1641username = "admin"
1642token_secret = "supersecretvalue"
1643
1644[routes]
1645"app.example.com" = "myapp:latest"
1646
1647[network_groups]
1648frontend = ["app1.example.com", "app2.example.com"]
1649`
1650 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1651 require.NoError(t, err)
1652
1653 v := viper.New()
1654 v.SetConfigFile(configFile)
1655 err = v.ReadInConfig()
1656 require.NoError(t, err)
1657
1658 eventBus := mocks.NewMockEventPublisher(t)
1659 svc := NewService(v, eventBus)
1660 ctx := testContext()
1661
1662 err = svc.Load(ctx)
1663 require.NoError(t, err)
1664
1665 // Verify network_groups loaded correctly in memory
1666 groups := svc.GetNetworkGroups()
1667 require.Len(t, groups, 1)
1668 require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
1669
1670 // Trigger a Save via AddRoute (which calls Save internally)
1671 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1672 require.NoError(t, err)
1673
1674 // Re-read the file to verify network_groups was persisted
1675 v2 := viper.New()
1676 v2.SetConfigFile(configFile)
1677 err = v2.ReadInConfig()
1678 require.NoError(t, err)
1679
1680 // Verify network_groups is still intact and correct after save
1681 savedGroups := loadStringArrayMap(v2.Get("network_groups"))
1682 assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config")
1683 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"])
1684
1685 // Verify auth fields are preserved
1686 assert.Equal(t, "admin", v2.GetString("auth.username"))
1687 assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret"))
1688
1689 // Verify server fields are preserved
1690 assert.Equal(t, 9999, v2.GetInt("server.port"))
1691 })
1692
1693 // Regression test: external_routes keys contain dots (e.g. "reg.example.com").
1694 // Without explicitly calling viper.Set("external_routes", ...) before WriteConfig,
1695 // viper splits dotted keys into nested subtrees, corrupting the data on re-read.
1696 t.Run("external_routes with dotted domain keys are not corrupted on Save", func(t *testing.T) {
1697 tmpDir := t.TempDir()
1698 configFile := filepath.Join(tmpDir, "gordon.toml")
1699 initialConfig := `
1700[server]
1701port = 8080
1702
1703[routes]
1704"app.example.com" = "myapp:latest"
1705
1706[external_routes]
1707"reg.example.com" = "localhost:5000"
1708`
1709 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1710 require.NoError(t, err)
1711
1712 v := viper.New()
1713 v.SetConfigFile(configFile)
1714 err = v.ReadInConfig()
1715 require.NoError(t, err)
1716
1717 eventBus := mocks.NewMockEventPublisher(t)
1718 svc := NewService(v, eventBus)
1719 ctx := testContext()
1720
1721 err = svc.Load(ctx)
1722 require.NoError(t, err)
1723
1724 // Verify external_routes loaded correctly in memory
1725 extRoutes := svc.GetExternalRoutes()
1726 require.Equal(t, "localhost:5000", extRoutes["reg.example.com"])
1727
1728 // Trigger Save
1729 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1730 require.NoError(t, err)
1731
1732 // Re-read the file with a fresh viper instance
1733 v2 := viper.New()
1734 v2.SetConfigFile(configFile)
1735 err = v2.ReadInConfig()
1736 require.NoError(t, err)
1737
1738 // external_routes must survive Save with correct flat map structure.
1739 // Without the fix, viper splits "reg.example.com" into nested TOML subtrees
1740 // and GetString("external_routes.reg\\.example\\.com") returns empty string.
1741 savedExtRoutes := loadStringMap(v2.Get("external_routes"))
1742 assert.Equal(t, "localhost:5000", savedExtRoutes["reg.example.com"],
1743 "external_routes must not be corrupted by viper's dot-path splitting on WriteConfig")
1744 })
1745
1746 t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) {
1747 // Setup: config file WITHOUT network_groups
1748 tmpDir := t.TempDir()
1749 configFile := filepath.Join(tmpDir, "gordon.toml")
1750 initialConfig := `
1751[server]
1752port = 8080
1753
1754[routes]
1755"app.example.com" = "myapp:latest"
1756`
1757 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1758 require.NoError(t, err)
1759
1760 v := viper.New()
1761 v.SetConfigFile(configFile)
1762 err = v.ReadInConfig()
1763 require.NoError(t, err)
1764
1765 // Inject network_groups into viper as if they were loaded from some other source
1766 // This simulates a case where network_groups exist in memory but not on disk
1767 v.Set("network_groups", map[string]interface{}{
1768 "backend": []interface{}{"api.example.com"},
1769 })
1770
1771 eventBus := mocks.NewMockEventPublisher(t)
1772 svc := NewService(v, eventBus)
1773 ctx := testContext()
1774
1775 err = svc.Load(ctx)
1776 require.NoError(t, err)
1777
1778 // Verify network_groups is loaded in memory
1779 groups := svc.GetNetworkGroups()
1780 require.Len(t, groups, 1)
1781
1782 // Trigger Save
1783 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1784 require.NoError(t, err)
1785
1786 // Re-read the file
1787 v2 := viper.New()
1788 v2.SetConfigFile(configFile)
1789 err = v2.ReadInConfig()
1790 require.NoError(t, err)
1791
1792 // network_groups must be present in the saved file
1793 savedGroups := loadStringArrayMap(v2.Get("network_groups"))
1794 assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save")
1795 assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"])
1796 })
1797}
1798
1799func TestExtractDomainFromImageName(t *testing.T) {
1800 tests := []struct {
1801 name string
1802 imageName string
1803 expectedDom string
1804 expectedFound bool
1805 }{
1806 {
1807 name: "domain like image",
1808 imageName: "myapp.example.com:latest",
1809 expectedDom: "myapp.example.com",
1810 expectedFound: true,
1811 },
1812 {
1813 name: "domain like image without tag",
1814 imageName: "api.backend.io",
1815 expectedDom: "api.backend.io",
1816 expectedFound: true,
1817 },
1818 {
1819 name: "simple image name",
1820 imageName: "nginx:latest",
1821 expectedDom: "",
1822 expectedFound: false,
1823 },
1824 {
1825 name: "registry path",
1826 imageName: "gcr.io/project/image:tag",
1827 expectedDom: "gcr.io/project/image",
1828 expectedFound: true,
1829 },
1830 {
1831 name: "leading dot",
1832 imageName: ".invalid:latest",
1833 expectedDom: "",
1834 expectedFound: false,
1835 },
1836 {
1837 name: "trailing dot",
1838 imageName: "invalid.:latest",
1839 expectedDom: "",
1840 expectedFound: false,
1841 },
1842 }
1843
1844 for _, tt := range tests {
1845 t.Run(tt.name, func(t *testing.T) {
1846 domain, found := ExtractDomainFromImageName(tt.imageName)
1847 assert.Equal(t, tt.expectedDom, domain)
1848 assert.Equal(t, tt.expectedFound, found)
1849 })
1850 }
1851}
1852
1853func TestLoadStringMap(t *testing.T) {
1854 tests := []struct {
1855 name string
1856 input any
1857 expected map[string]string
1858 }{
1859 {
1860 name: "nil input",
1861 input: nil,
1862 expected: map[string]string{},
1863 },
1864 {
1865 name: "valid map",
1866 input: map[string]any{
1867 "key1": "value1",
1868 "key2": "value2",
1869 },
1870 expected: map[string]string{
1871 "key1": "value1",
1872 "key2": "value2",
1873 },
1874 },
1875 {
1876 name: "invalid type",
1877 input: "not a map",
1878 expected: map[string]string{},
1879 },
1880 {
1881 name: "mixed values",
1882 input: map[string]any{
1883 "string": "value",
1884 "int": 123,
1885 },
1886 expected: map[string]string{
1887 "string": "value",
1888 },
1889 },
1890 }
1891
1892 for _, tt := range tests {
1893 t.Run(tt.name, func(t *testing.T) {
1894 result := loadStringMap(tt.input)
1895 assert.Equal(t, tt.expected, result)
1896 })
1897 }
1898}
1899
1900func TestLoadStringArrayMap(t *testing.T) {
1901 tests := []struct {
1902 name string
1903 input any
1904 expected map[string][]string
1905 }{
1906 {
1907 name: "nil input",
1908 input: nil,
1909 expected: map[string][]string{},
1910 },
1911 {
1912 name: "valid map",
1913 input: map[string]any{
1914 "group1": []any{"a", "b"},
1915 "group2": []any{"c", "d", "e"},
1916 },
1917 expected: map[string][]string{
1918 "group1": {"a", "b"},
1919 "group2": {"c", "d", "e"},
1920 },
1921 },
1922 {
1923 name: "invalid type",
1924 input: "not a map",
1925 expected: map[string][]string{},
1926 },
1927 {
1928 name: "non-array value",
1929 input: map[string]any{
1930 "array": []any{"a", "b"},
1931 "string": "not an array",
1932 },
1933 expected: map[string][]string{
1934 "array": {"a", "b"},
1935 },
1936 },
1937 }
1938
1939 for _, tt := range tests {
1940 t.Run(tt.name, func(t *testing.T) {
1941 result := loadStringArrayMap(tt.input)
1942 assert.Equal(t, tt.expected, result)
1943 })
1944 }
1945}