1package config
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/bnema/zerowrap"
10 "github.com/spf13/viper"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/gordon/internal/boundaries/out/mocks"
15 "github.com/bnema/gordon/internal/domain"
16)
17
18func testContext() context.Context {
19 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
20}
21
22func TestService_Load(t *testing.T) {
23 v := viper.New()
24 v.Set("server.port", 8080)
25 v.Set("server.registry_port", 5000)
26 v.Set("server.registry_domain", "registry.example.com")
27 v.Set("server.data_dir", "/var/gordon")
28 v.Set("auto_route.enabled", true)
29 v.Set("network_isolation.enabled", true)
30 v.Set("network_isolation.network_prefix", "gordon")
31 v.Set("auth.enabled", true)
32 v.Set("auth.username", "admin")
33 v.Set("auth.password", "secret")
34 v.Set("volumes.auto_create", true)
35 v.Set("volumes.prefix", "gordon")
36 v.Set("volumes.preserve", false)
37 v.Set("routes", map[string]interface{}{
38 "app.example.com": "myapp:latest",
39 })
40
41 eventBus := mocks.NewMockEventPublisher(t)
42 svc := NewService(v, eventBus)
43 ctx := testContext()
44
45 err := svc.Load(ctx)
46
47 assert.NoError(t, err)
48 assert.Equal(t, 8080, svc.GetServerPort())
49 assert.Equal(t, 5000, svc.GetRegistryPort())
50 assert.Equal(t, "registry.example.com", svc.GetRegistryDomain())
51 assert.Equal(t, "/var/gordon", svc.GetDataDir())
52 assert.True(t, svc.IsAutoRouteEnabled())
53 assert.True(t, svc.IsNetworkIsolationEnabled())
54 assert.Equal(t, "gordon", svc.GetNetworkPrefix())
55
56 enabled, username, password := svc.GetRegistryAuthConfig()
57 assert.True(t, enabled)
58 assert.Equal(t, "admin", username)
59 assert.Equal(t, "secret", password)
60
61 autoCreate, prefix, preserve := svc.GetVolumeConfig()
62 assert.True(t, autoCreate)
63 assert.Equal(t, "gordon", prefix)
64 assert.False(t, preserve)
65}
66
67func TestService_Reload(t *testing.T) {
68 t.Run("success - picks up config file changes", func(t *testing.T) {
69 // Create temp config file
70 tmpDir := t.TempDir()
71 configFile := filepath.Join(tmpDir, "gordon.toml")
72 initialConfig := `[server]
73port = 8080
74
75[routes]
76"app.example.com" = "myapp:v1"
77`
78 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
79 require.NoError(t, err)
80
81 v := viper.New()
82 v.SetConfigFile(configFile)
83 err = v.ReadInConfig()
84 require.NoError(t, err)
85
86 eventBus := mocks.NewMockEventPublisher(t)
87 svc := NewService(v, eventBus)
88 ctx := testContext()
89
90 // Initial load
91 err = svc.Load(ctx)
92 require.NoError(t, err)
93 assert.Equal(t, 8080, svc.GetServerPort())
94 assert.Equal(t, "myapp:v1", svc.GetConfig().Routes["app.example.com"])
95
96 // Modify config file on disk
97 updatedConfig := `[server]
98port = 9090
99
100[routes]
101"app.example.com" = "myapp:v2"
102"new.example.com" = "newapp:latest"
103`
104 err = os.WriteFile(configFile, []byte(updatedConfig), 0600)
105 require.NoError(t, err)
106
107 // Reload should pick up new values
108 err = svc.Reload(ctx)
109 require.NoError(t, err)
110
111 assert.Equal(t, 9090, svc.GetServerPort())
112 assert.Equal(t, "myapp:v2", svc.GetConfig().Routes["app.example.com"])
113 assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
114 })
115
116 t.Run("error - config file not found", func(t *testing.T) {
117 tmpDir := t.TempDir()
118 configFile := filepath.Join(tmpDir, "gordon.toml")
119
120 // Create config, load it, then delete it
121 err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
122 require.NoError(t, err)
123
124 v := viper.New()
125 v.SetConfigFile(configFile)
126 err = v.ReadInConfig()
127 require.NoError(t, err)
128
129 eventBus := mocks.NewMockEventPublisher(t)
130 svc := NewService(v, eventBus)
131 ctx := testContext()
132
133 err = svc.Load(ctx)
134 require.NoError(t, err)
135
136 // Delete the config file
137 err = os.Remove(configFile)
138 require.NoError(t, err)
139
140 // Reload should fail
141 err = svc.Reload(ctx)
142 assert.Error(t, err)
143 assert.Contains(t, err.Error(), "failed to read config file")
144 })
145
146 t.Run("error - invalid config syntax", func(t *testing.T) {
147 tmpDir := t.TempDir()
148 configFile := filepath.Join(tmpDir, "gordon.toml")
149
150 // Create valid config initially
151 err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
152 require.NoError(t, err)
153
154 v := viper.New()
155 v.SetConfigFile(configFile)
156 err = v.ReadInConfig()
157 require.NoError(t, err)
158
159 eventBus := mocks.NewMockEventPublisher(t)
160 svc := NewService(v, eventBus)
161 ctx := testContext()
162
163 err = svc.Load(ctx)
164 require.NoError(t, err)
165
166 // Write invalid TOML
167 err = os.WriteFile(configFile, []byte("[server\nport = invalid syntax"), 0600)
168 require.NoError(t, err)
169
170 // Reload should fail
171 err = svc.Reload(ctx)
172 assert.Error(t, err)
173 })
174}
175
176func TestService_GetRoutes(t *testing.T) {
177 v := viper.New()
178 v.Set("routes", map[string]interface{}{
179 "app1.example.com": "myapp:latest",
180 "app2.example.com": "otherapp:v2",
181 "http://insecure.example": "insecureapp:latest",
182 })
183
184 eventBus := mocks.NewMockEventPublisher(t)
185 svc := NewService(v, eventBus)
186 ctx := testContext()
187
188 _ = svc.Load(ctx)
189
190 routes := svc.GetRoutes(ctx)
191
192 assert.Len(t, routes, 3)
193
194 // Check routes (order is not guaranteed)
195 routeMap := make(map[string]domain.Route)
196 for _, r := range routes {
197 routeMap[r.Domain] = r
198 }
199
200 assert.Equal(t, "myapp:latest", routeMap["app1.example.com"].Image)
201 assert.True(t, routeMap["app1.example.com"].HTTPS)
202
203 assert.Equal(t, "otherapp:v2", routeMap["app2.example.com"].Image)
204 assert.True(t, routeMap["app2.example.com"].HTTPS)
205
206 assert.Equal(t, "insecureapp:latest", routeMap["insecure.example"].Image)
207 assert.False(t, routeMap["insecure.example"].HTTPS)
208}
209
210func TestService_GetRoute(t *testing.T) {
211 v := viper.New()
212 v.Set("routes", map[string]interface{}{
213 "app.example.com": "myapp:latest",
214 "http://insecure.example": "insecureapp:latest",
215 })
216
217 eventBus := mocks.NewMockEventPublisher(t)
218 svc := NewService(v, eventBus)
219 ctx := testContext()
220
221 _ = svc.Load(ctx)
222
223 t.Run("existing route", func(t *testing.T) {
224 route, err := svc.GetRoute(ctx, "app.example.com")
225 require.NoError(t, err)
226 assert.Equal(t, "app.example.com", route.Domain)
227 assert.Equal(t, "myapp:latest", route.Image)
228 assert.True(t, route.HTTPS)
229 })
230
231 t.Run("non-existent route", func(t *testing.T) {
232 route, err := svc.GetRoute(ctx, "notfound.example.com")
233 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
234 assert.Nil(t, route)
235 })
236}
237
238func TestService_AddRoute(t *testing.T) {
239 t.Run("success", func(t *testing.T) {
240 // Create temp config file for Save() to work
241 tmpDir := t.TempDir()
242 configFile := filepath.Join(tmpDir, "gordon.toml")
243 err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
244 require.NoError(t, err)
245
246 v := viper.New()
247 v.SetConfigFile(configFile)
248 eventBus := mocks.NewMockEventPublisher(t)
249 svc := NewService(v, eventBus)
250 ctx := testContext()
251
252 _ = svc.Load(ctx)
253
254 route := domain.Route{
255 Domain: "new.example.com",
256 Image: "newapp:latest",
257 }
258
259 err = svc.AddRoute(ctx, route)
260
261 assert.NoError(t, err)
262
263 // Verify route was added
264 config := svc.GetConfig()
265 assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
266 })
267
268 t.Run("empty domain", func(t *testing.T) {
269 v := viper.New()
270 eventBus := mocks.NewMockEventPublisher(t)
271 svc := NewService(v, eventBus)
272 ctx := testContext()
273
274 route := domain.Route{
275 Domain: "",
276 Image: "myapp:latest",
277 }
278
279 err := svc.AddRoute(ctx, route)
280 assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
281 })
282
283 t.Run("empty image", func(t *testing.T) {
284 v := viper.New()
285 eventBus := mocks.NewMockEventPublisher(t)
286 svc := NewService(v, eventBus)
287 ctx := testContext()
288
289 route := domain.Route{
290 Domain: "example.com",
291 Image: "",
292 }
293
294 err := svc.AddRoute(ctx, route)
295 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
296 })
297}
298
299func TestService_UpdateRoute(t *testing.T) {
300 t.Run("success", func(t *testing.T) {
301 // Create temp config file for Save() to work
302 tmpDir := t.TempDir()
303 configFile := filepath.Join(tmpDir, "gordon.toml")
304 err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600)
305 require.NoError(t, err)
306
307 v := viper.New()
308 v.SetConfigFile(configFile)
309 v.Set("routes", map[string]any{
310 "app.example.com": "myapp:v1",
311 })
312
313 eventBus := mocks.NewMockEventPublisher(t)
314 svc := NewService(v, eventBus)
315 ctx := testContext()
316
317 _ = svc.Load(ctx)
318
319 route := domain.Route{
320 Domain: "app.example.com",
321 Image: "myapp:v2",
322 }
323
324 err = svc.UpdateRoute(ctx, route)
325
326 assert.NoError(t, err)
327
328 config := svc.GetConfig()
329 assert.Equal(t, "myapp:v2", config.Routes["app.example.com"])
330 })
331
332 t.Run("not found", func(t *testing.T) {
333 v := viper.New()
334 eventBus := mocks.NewMockEventPublisher(t)
335 svc := NewService(v, eventBus)
336 ctx := testContext()
337
338 _ = svc.Load(ctx)
339
340 route := domain.Route{
341 Domain: "nonexistent.example.com",
342 Image: "myapp:latest",
343 }
344
345 err := svc.UpdateRoute(ctx, route)
346 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
347 })
348
349 t.Run("empty domain", func(t *testing.T) {
350 v := viper.New()
351 eventBus := mocks.NewMockEventPublisher(t)
352 svc := NewService(v, eventBus)
353 ctx := testContext()
354
355 route := domain.Route{
356 Domain: "",
357 Image: "myapp:latest",
358 }
359
360 err := svc.UpdateRoute(ctx, route)
361 assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
362 })
363
364 t.Run("empty image", func(t *testing.T) {
365 v := viper.New()
366 eventBus := mocks.NewMockEventPublisher(t)
367 svc := NewService(v, eventBus)
368 ctx := testContext()
369
370 route := domain.Route{
371 Domain: "example.com",
372 Image: "",
373 }
374
375 err := svc.UpdateRoute(ctx, route)
376 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
377 })
378}
379
380func TestService_RemoveRoute(t *testing.T) {
381 // Create temp config file for Save() to work
382 tmpDir := t.TempDir()
383 configFile := filepath.Join(tmpDir, "gordon.toml")
384 err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
385 require.NoError(t, err)
386
387 v := viper.New()
388 v.SetConfigFile(configFile)
389 v.Set("routes", map[string]interface{}{
390 "app.example.com": "myapp:latest",
391 })
392
393 eventBus := mocks.NewMockEventPublisher(t)
394 svc := NewService(v, eventBus)
395 ctx := testContext()
396
397 _ = svc.Load(ctx)
398
399 err = svc.RemoveRoute(ctx, "app.example.com")
400
401 assert.NoError(t, err)
402
403 config := svc.GetConfig()
404 _, exists := config.Routes["app.example.com"]
405 assert.False(t, exists)
406}
407
408func TestService_RemoveRoute_NotFound(t *testing.T) {
409 v := viper.New()
410 eventBus := mocks.NewMockEventPublisher(t)
411 svc := NewService(v, eventBus)
412 ctx := testContext()
413
414 _ = svc.Load(ctx)
415
416 err := svc.RemoveRoute(ctx, "nonexistent.example.com")
417
418 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
419}
420
421func TestService_GetNetworkGroups(t *testing.T) {
422 v := viper.New()
423 v.Set("network_groups", map[string]interface{}{
424 "frontend": []interface{}{"app1.example.com", "app2.example.com"},
425 "backend": []interface{}{"api.example.com", "db.example.com"},
426 })
427
428 eventBus := mocks.NewMockEventPublisher(t)
429 svc := NewService(v, eventBus)
430 ctx := testContext()
431
432 _ = svc.Load(ctx)
433
434 groups := svc.GetNetworkGroups()
435
436 assert.Len(t, groups, 2)
437 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
438 assert.ElementsMatch(t, []string{"api.example.com", "db.example.com"}, groups["backend"])
439}
440
441func TestService_GetAttachments(t *testing.T) {
442 v := viper.New()
443 v.Set("attachments", map[string]interface{}{
444 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
445 })
446
447 eventBus := mocks.NewMockEventPublisher(t)
448 svc := NewService(v, eventBus)
449 ctx := testContext()
450
451 _ = svc.Load(ctx)
452
453 attachments := svc.GetAttachments()
454
455 assert.Len(t, attachments, 1)
456 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
457}
458
459func TestService_GetAllAttachments(t *testing.T) {
460 t.Run("returns all attachments", func(t *testing.T) {
461 v := viper.New()
462 v.Set("attachments", map[string]interface{}{
463 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
464 "api.example.com": []interface{}{"rabbitmq:3"},
465 })
466
467 eventBus := mocks.NewMockEventPublisher(t)
468 svc := NewService(v, eventBus)
469 ctx := testContext()
470
471 _ = svc.Load(ctx)
472
473 attachments := svc.GetAllAttachments(ctx)
474
475 assert.Len(t, attachments, 2)
476 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
477 assert.ElementsMatch(t, []string{"rabbitmq:3"}, attachments["api.example.com"])
478 })
479
480 t.Run("returns empty map when no attachments", func(t *testing.T) {
481 v := viper.New()
482 eventBus := mocks.NewMockEventPublisher(t)
483 svc := NewService(v, eventBus)
484 ctx := testContext()
485
486 _ = svc.Load(ctx)
487
488 attachments := svc.GetAllAttachments(ctx)
489
490 assert.Empty(t, attachments)
491 })
492
493 t.Run("returns copy not reference", func(t *testing.T) {
494 v := viper.New()
495 v.Set("attachments", map[string]interface{}{
496 "app.example.com": []interface{}{"redis:latest"},
497 })
498
499 eventBus := mocks.NewMockEventPublisher(t)
500 svc := NewService(v, eventBus)
501 ctx := testContext()
502
503 _ = svc.Load(ctx)
504
505 attachments := svc.GetAllAttachments(ctx)
506 // Modify the returned map
507 attachments["app.example.com"] = append(attachments["app.example.com"], "postgres:18")
508
509 // Original should be unchanged
510 original := svc.GetAllAttachments(ctx)
511 assert.Len(t, original["app.example.com"], 1)
512 })
513}
514
515func TestService_GetAttachmentsFor(t *testing.T) {
516 t.Run("existing domain", func(t *testing.T) {
517 v := viper.New()
518 v.Set("attachments", map[string]interface{}{
519 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
520 })
521
522 eventBus := mocks.NewMockEventPublisher(t)
523 svc := NewService(v, eventBus)
524 ctx := testContext()
525
526 _ = svc.Load(ctx)
527
528 images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
529
530 require.NoError(t, err)
531 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, images)
532 })
533
534 t.Run("existing network group", func(t *testing.T) {
535 v := viper.New()
536 v.Set("attachments", map[string]interface{}{
537 "backend": []interface{}{"rabbitmq:3", "redis:latest"},
538 })
539
540 eventBus := mocks.NewMockEventPublisher(t)
541 svc := NewService(v, eventBus)
542 ctx := testContext()
543
544 _ = svc.Load(ctx)
545
546 images, err := svc.GetAttachmentsFor(ctx, "backend")
547
548 require.NoError(t, err)
549 assert.ElementsMatch(t, []string{"rabbitmq:3", "redis:latest"}, images)
550 })
551
552 t.Run("non-existent target", func(t *testing.T) {
553 v := viper.New()
554 eventBus := mocks.NewMockEventPublisher(t)
555 svc := NewService(v, eventBus)
556 ctx := testContext()
557
558 _ = svc.Load(ctx)
559
560 images, err := svc.GetAttachmentsFor(ctx, "notfound.example.com")
561
562 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
563 assert.Nil(t, images)
564 })
565
566 t.Run("returns copy not reference", func(t *testing.T) {
567 v := viper.New()
568 v.Set("attachments", map[string]interface{}{
569 "app.example.com": []interface{}{"redis:latest"},
570 })
571
572 eventBus := mocks.NewMockEventPublisher(t)
573 svc := NewService(v, eventBus)
574 ctx := testContext()
575
576 _ = svc.Load(ctx)
577
578 images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
579 require.NoError(t, err)
580
581 // Modify the returned slice (use _ to satisfy linter)
582 _ = append(images, "postgres:18")
583
584 // Original should be unchanged
585 original, _ := svc.GetAttachmentsFor(ctx, "app.example.com")
586 assert.Len(t, original, 1)
587 })
588}
589
590func TestService_AddAttachment(t *testing.T) {
591 t.Run("success - new target", func(t *testing.T) {
592 tmpDir := t.TempDir()
593 configFile := filepath.Join(tmpDir, "gordon.toml")
594 err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
595 require.NoError(t, err)
596
597 v := viper.New()
598 v.SetConfigFile(configFile)
599 eventBus := mocks.NewMockEventPublisher(t)
600 svc := NewService(v, eventBus)
601 ctx := testContext()
602
603 _ = svc.Load(ctx)
604
605 err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
606
607 assert.NoError(t, err)
608
609 // Verify attachment was added
610 config := svc.GetConfig()
611 assert.Contains(t, config.Attachments["app.example.com"], "postgres:18")
612 })
613
614 t.Run("success - existing target", func(t *testing.T) {
615 tmpDir := t.TempDir()
616 configFile := filepath.Join(tmpDir, "gordon.toml")
617 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
618 require.NoError(t, err)
619
620 v := viper.New()
621 v.SetConfigFile(configFile)
622 v.Set("attachments", map[string]interface{}{
623 "app.example.com": []interface{}{"redis:latest"},
624 })
625 eventBus := mocks.NewMockEventPublisher(t)
626 svc := NewService(v, eventBus)
627 ctx := testContext()
628
629 _ = svc.Load(ctx)
630
631 err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
632
633 assert.NoError(t, err)
634
635 // Verify attachment was added alongside existing
636 config := svc.GetConfig()
637 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
638 })
639
640 t.Run("duplicate attachment", func(t *testing.T) {
641 v := viper.New()
642 v.Set("attachments", map[string]interface{}{
643 "app.example.com": []interface{}{"redis:latest"},
644 })
645 eventBus := mocks.NewMockEventPublisher(t)
646 svc := NewService(v, eventBus)
647 ctx := testContext()
648
649 _ = svc.Load(ctx)
650
651 err := svc.AddAttachment(ctx, "app.example.com", "redis:latest")
652
653 assert.ErrorIs(t, err, domain.ErrAttachmentExists)
654 })
655
656 t.Run("empty target", func(t *testing.T) {
657 v := viper.New()
658 eventBus := mocks.NewMockEventPublisher(t)
659 svc := NewService(v, eventBus)
660 ctx := testContext()
661
662 err := svc.AddAttachment(ctx, "", "postgres:18")
663
664 assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
665 })
666
667 t.Run("empty image", func(t *testing.T) {
668 v := viper.New()
669 eventBus := mocks.NewMockEventPublisher(t)
670 svc := NewService(v, eventBus)
671 ctx := testContext()
672
673 err := svc.AddAttachment(ctx, "app.example.com", "")
674
675 assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
676 })
677
678 t.Run("network group target", func(t *testing.T) {
679 tmpDir := t.TempDir()
680 configFile := filepath.Join(tmpDir, "gordon.toml")
681 err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
682 require.NoError(t, err)
683
684 v := viper.New()
685 v.SetConfigFile(configFile)
686 eventBus := mocks.NewMockEventPublisher(t)
687 svc := NewService(v, eventBus)
688 ctx := testContext()
689
690 _ = svc.Load(ctx)
691
692 err = svc.AddAttachment(ctx, "backend", "rabbitmq:3")
693
694 assert.NoError(t, err)
695
696 config := svc.GetConfig()
697 assert.Contains(t, config.Attachments["backend"], "rabbitmq:3")
698 })
699}
700
701func TestService_RemoveAttachment(t *testing.T) {
702 t.Run("success - single attachment", func(t *testing.T) {
703 tmpDir := t.TempDir()
704 configFile := filepath.Join(tmpDir, "gordon.toml")
705 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
706 require.NoError(t, err)
707
708 v := viper.New()
709 v.SetConfigFile(configFile)
710 v.Set("attachments", map[string]interface{}{
711 "app.example.com": []interface{}{"redis:latest"},
712 })
713 eventBus := mocks.NewMockEventPublisher(t)
714 svc := NewService(v, eventBus)
715 ctx := testContext()
716
717 _ = svc.Load(ctx)
718
719 err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
720
721 assert.NoError(t, err)
722
723 // Verify target was removed (no attachments left)
724 config := svc.GetConfig()
725 _, exists := config.Attachments["app.example.com"]
726 assert.False(t, exists)
727 })
728
729 t.Run("success - multiple attachments", func(t *testing.T) {
730 tmpDir := t.TempDir()
731 configFile := filepath.Join(tmpDir, "gordon.toml")
732 err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\", \"postgres:18\"]\n"), 0600)
733 require.NoError(t, err)
734
735 v := viper.New()
736 v.SetConfigFile(configFile)
737 v.Set("attachments", map[string]interface{}{
738 "app.example.com": []interface{}{"redis:latest", "postgres:18"},
739 })
740 eventBus := mocks.NewMockEventPublisher(t)
741 svc := NewService(v, eventBus)
742 ctx := testContext()
743
744 _ = svc.Load(ctx)
745
746 err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
747
748 assert.NoError(t, err)
749
750 // Verify only redis was removed
751 config := svc.GetConfig()
752 assert.Equal(t, []string{"postgres:18"}, config.Attachments["app.example.com"])
753 })
754
755 t.Run("target not found", func(t *testing.T) {
756 v := viper.New()
757 eventBus := mocks.NewMockEventPublisher(t)
758 svc := NewService(v, eventBus)
759 ctx := testContext()
760
761 _ = svc.Load(ctx)
762
763 err := svc.RemoveAttachment(ctx, "notfound.example.com", "redis:latest")
764
765 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
766 })
767
768 t.Run("image not found in target", func(t *testing.T) {
769 v := viper.New()
770 v.Set("attachments", map[string]interface{}{
771 "app.example.com": []interface{}{"redis:latest"},
772 })
773 eventBus := mocks.NewMockEventPublisher(t)
774 svc := NewService(v, eventBus)
775 ctx := testContext()
776
777 _ = svc.Load(ctx)
778
779 err := svc.RemoveAttachment(ctx, "app.example.com", "postgres:18")
780
781 assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
782 })
783
784 t.Run("empty target", func(t *testing.T) {
785 v := viper.New()
786 eventBus := mocks.NewMockEventPublisher(t)
787 svc := NewService(v, eventBus)
788 ctx := testContext()
789
790 err := svc.RemoveAttachment(ctx, "", "redis:latest")
791
792 assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
793 })
794
795 t.Run("empty image", func(t *testing.T) {
796 v := viper.New()
797 eventBus := mocks.NewMockEventPublisher(t)
798 svc := NewService(v, eventBus)
799 ctx := testContext()
800
801 err := svc.RemoveAttachment(ctx, "app.example.com", "")
802
803 assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
804 })
805
806 t.Run("network group target", func(t *testing.T) {
807 tmpDir := t.TempDir()
808 configFile := filepath.Join(tmpDir, "gordon.toml")
809 err := os.WriteFile(configFile, []byte("[attachments]\n\"backend\" = [\"rabbitmq:3\"]\n"), 0600)
810 require.NoError(t, err)
811
812 v := viper.New()
813 v.SetConfigFile(configFile)
814 v.Set("attachments", map[string]interface{}{
815 "backend": []interface{}{"rabbitmq:3"},
816 })
817 eventBus := mocks.NewMockEventPublisher(t)
818 svc := NewService(v, eventBus)
819 ctx := testContext()
820
821 _ = svc.Load(ctx)
822
823 err = svc.RemoveAttachment(ctx, "backend", "rabbitmq:3")
824
825 assert.NoError(t, err)
826
827 config := svc.GetConfig()
828 _, exists := config.Attachments["backend"]
829 assert.False(t, exists)
830 })
831}
832
833func TestService_GetExternalRoutes(t *testing.T) {
834 v := viper.New()
835 v.Set("external_routes", map[string]interface{}{
836 "reg.example.com": "localhost:5000",
837 "cache.example.com": "127.0.0.1:6379",
838 })
839
840 eventBus := mocks.NewMockEventPublisher(t)
841 svc := NewService(v, eventBus)
842 ctx := testContext()
843
844 _ = svc.Load(ctx)
845
846 routes := svc.GetExternalRoutes()
847
848 assert.Len(t, routes, 2)
849 assert.Equal(t, "localhost:5000", routes["reg.example.com"])
850 assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
851}
852
853func TestService_GetExternalRoutes_Empty(t *testing.T) {
854 v := viper.New()
855
856 eventBus := mocks.NewMockEventPublisher(t)
857 svc := NewService(v, eventBus)
858 ctx := testContext()
859
860 _ = svc.Load(ctx)
861
862 routes := svc.GetExternalRoutes()
863
864 assert.Empty(t, routes)
865}
866
867func TestSplitImageNameTag(t *testing.T) {
868 tests := []struct {
869 name string
870 image string
871 wantName string
872 wantHasTag bool
873 }{
874 {"with tag", "myapp:latest", "myapp", true},
875 {"with version tag", "myapp:v1.2.0", "myapp", true},
876 {"no tag", "myapp", "myapp", false},
877 {"empty string", "", "", false},
878 {"tag only colon", "myapp:", "myapp", true},
879 {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true},
880 {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false},
881 }
882
883 for _, tt := range tests {
884 t.Run(tt.name, func(t *testing.T) {
885 name, hasTag := splitImageNameTag(tt.image)
886 assert.Equal(t, tt.wantName, name)
887 assert.Equal(t, tt.wantHasTag, hasTag)
888 })
889 }
890}
891
892func TestNormalizeRegistryImage(t *testing.T) {
893 tests := []struct {
894 name string
895 imageName string
896 registryDomain string
897 expected string
898 }{
899 {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
900 {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
901 {"empty registry domain", "myapp:latest", "", "myapp:latest"},
902 {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
903 {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
904 {"bare name no registry", "myapp", "reg.example.com", "myapp"},
905 {"exact registry without image", "reg.example.com/", "reg.example.com", ""},
906 }
907
908 for _, tt := range tests {
909 t.Run(tt.name, func(t *testing.T) {
910 result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
911 assert.Equal(t, tt.expected, result)
912 })
913 }
914}
915
916func TestService_FindRoutesByImage(t *testing.T) {
917 t.Run("exact match with tag", func(t *testing.T) {
918 v := viper.New()
919 v.Set("routes", map[string]interface{}{
920 "app.example.com": "myapp:latest",
921 })
922
923 eventBus := mocks.NewMockEventPublisher(t)
924 svc := NewService(v, eventBus)
925 ctx := testContext()
926 _ = svc.Load(ctx)
927
928 routes := svc.FindRoutesByImage(ctx, "myapp:latest")
929 assert.Len(t, routes, 1)
930 assert.Equal(t, "app.example.com", routes[0].Domain)
931 assert.Equal(t, "myapp:latest", routes[0].Image)
932 })
933
934 t.Run("bare name matches route with tag", func(t *testing.T) {
935 v := viper.New()
936 v.Set("routes", map[string]interface{}{
937 "app.example.com": "myapp:latest",
938 })
939
940 eventBus := mocks.NewMockEventPublisher(t)
941 svc := NewService(v, eventBus)
942 ctx := testContext()
943 _ = svc.Load(ctx)
944
945 routes := svc.FindRoutesByImage(ctx, "myapp")
946 assert.Len(t, routes, 1)
947 assert.Equal(t, "app.example.com", routes[0].Domain)
948 })
949
950 t.Run("bare name matches route with version tag", func(t *testing.T) {
951 v := viper.New()
952 v.Set("routes", map[string]interface{}{
953 "app.example.com": "myapp:v2.0.0",
954 })
955
956 eventBus := mocks.NewMockEventPublisher(t)
957 svc := NewService(v, eventBus)
958 ctx := testContext()
959 _ = svc.Load(ctx)
960
961 routes := svc.FindRoutesByImage(ctx, "myapp")
962 assert.Len(t, routes, 1)
963 assert.Equal(t, "app.example.com", routes[0].Domain)
964 })
965
966 t.Run("tag mismatch does not match", func(t *testing.T) {
967 v := viper.New()
968 v.Set("routes", map[string]interface{}{
969 "app.example.com": "myapp:v1",
970 })
971
972 eventBus := mocks.NewMockEventPublisher(t)
973 svc := NewService(v, eventBus)
974 ctx := testContext()
975 _ = svc.Load(ctx)
976
977 routes := svc.FindRoutesByImage(ctx, "myapp:v2")
978 assert.Empty(t, routes)
979 })
980
981 t.Run("no match", func(t *testing.T) {
982 v := viper.New()
983 v.Set("routes", map[string]interface{}{
984 "app.example.com": "myapp:latest",
985 })
986
987 eventBus := mocks.NewMockEventPublisher(t)
988 svc := NewService(v, eventBus)
989 ctx := testContext()
990 _ = svc.Load(ctx)
991
992 routes := svc.FindRoutesByImage(ctx, "otherapp")
993 assert.Empty(t, routes)
994 })
995
996 t.Run("strips registry prefix before matching", func(t *testing.T) {
997 v := viper.New()
998 v.Set("server.registry_domain", "reg.example.com")
999 v.Set("routes", map[string]interface{}{
1000 "app.example.com": "reg.example.com/myapp:latest",
1001 })
1002
1003 eventBus := mocks.NewMockEventPublisher(t)
1004 svc := NewService(v, eventBus)
1005 ctx := testContext()
1006 _ = svc.Load(ctx)
1007
1008 routes := svc.FindRoutesByImage(ctx, "myapp")
1009 assert.Len(t, routes, 1)
1010 assert.Equal(t, "app.example.com", routes[0].Domain)
1011 })
1012
1013 t.Run("strips registry prefix from input too", func(t *testing.T) {
1014 v := viper.New()
1015 v.Set("server.registry_domain", "reg.example.com")
1016 v.Set("routes", map[string]interface{}{
1017 "app.example.com": "reg.example.com/myapp:latest",
1018 })
1019
1020 eventBus := mocks.NewMockEventPublisher(t)
1021 svc := NewService(v, eventBus)
1022 ctx := testContext()
1023 _ = svc.Load(ctx)
1024
1025 routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest")
1026 assert.Len(t, routes, 1)
1027 assert.Equal(t, "app.example.com", routes[0].Domain)
1028 })
1029
1030 t.Run("multiple routes for same image", func(t *testing.T) {
1031 v := viper.New()
1032 v.Set("routes", map[string]interface{}{
1033 "app1.example.com": "myapp:latest",
1034 "app2.example.com": "myapp:latest",
1035 "other.example.com": "otherapp:latest",
1036 })
1037
1038 eventBus := mocks.NewMockEventPublisher(t)
1039 svc := NewService(v, eventBus)
1040 ctx := testContext()
1041 _ = svc.Load(ctx)
1042
1043 routes := svc.FindRoutesByImage(ctx, "myapp")
1044 assert.Len(t, routes, 2)
1045
1046 domains := []string{routes[0].Domain, routes[1].Domain}
1047 assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains)
1048 })
1049
1050 t.Run("case insensitive match", func(t *testing.T) {
1051 v := viper.New()
1052 v.Set("routes", map[string]interface{}{
1053 "app.example.com": "MyApp:latest",
1054 })
1055
1056 eventBus := mocks.NewMockEventPublisher(t)
1057 svc := NewService(v, eventBus)
1058 ctx := testContext()
1059 _ = svc.Load(ctx)
1060
1061 routes := svc.FindRoutesByImage(ctx, "myapp")
1062 assert.Len(t, routes, 1)
1063 })
1064
1065 t.Run("http prefix route sets HTTPS false", func(t *testing.T) {
1066 v := viper.New()
1067 v.Set("routes", map[string]interface{}{
1068 "http://insecure.example.com": "myapp:latest",
1069 })
1070
1071 eventBus := mocks.NewMockEventPublisher(t)
1072 svc := NewService(v, eventBus)
1073 ctx := testContext()
1074 _ = svc.Load(ctx)
1075
1076 routes := svc.FindRoutesByImage(ctx, "myapp")
1077 assert.Len(t, routes, 1)
1078 assert.Equal(t, "insecure.example.com", routes[0].Domain)
1079 assert.False(t, routes[0].HTTPS)
1080 })
1081
1082 t.Run("empty routes", func(t *testing.T) {
1083 v := viper.New()
1084 eventBus := mocks.NewMockEventPublisher(t)
1085 svc := NewService(v, eventBus)
1086 ctx := testContext()
1087 _ = svc.Load(ctx)
1088
1089 routes := svc.FindRoutesByImage(ctx, "myapp")
1090 assert.Empty(t, routes)
1091 })
1092}
1093
1094func TestExtractDomainFromImageName(t *testing.T) {
1095 tests := []struct {
1096 name string
1097 imageName string
1098 expectedDom string
1099 expectedFound bool
1100 }{
1101 {
1102 name: "domain like image",
1103 imageName: "myapp.example.com:latest",
1104 expectedDom: "myapp.example.com",
1105 expectedFound: true,
1106 },
1107 {
1108 name: "domain like image without tag",
1109 imageName: "api.backend.io",
1110 expectedDom: "api.backend.io",
1111 expectedFound: true,
1112 },
1113 {
1114 name: "simple image name",
1115 imageName: "nginx:latest",
1116 expectedDom: "",
1117 expectedFound: false,
1118 },
1119 {
1120 name: "registry path",
1121 imageName: "gcr.io/project/image:tag",
1122 expectedDom: "gcr.io/project/image",
1123 expectedFound: true,
1124 },
1125 {
1126 name: "leading dot",
1127 imageName: ".invalid:latest",
1128 expectedDom: "",
1129 expectedFound: false,
1130 },
1131 {
1132 name: "trailing dot",
1133 imageName: "invalid.:latest",
1134 expectedDom: "",
1135 expectedFound: false,
1136 },
1137 }
1138
1139 for _, tt := range tests {
1140 t.Run(tt.name, func(t *testing.T) {
1141 domain, found := ExtractDomainFromImageName(tt.imageName)
1142 assert.Equal(t, tt.expectedDom, domain)
1143 assert.Equal(t, tt.expectedFound, found)
1144 })
1145 }
1146}
1147
1148func TestLoadStringMap(t *testing.T) {
1149 tests := []struct {
1150 name string
1151 input any
1152 expected map[string]string
1153 }{
1154 {
1155 name: "nil input",
1156 input: nil,
1157 expected: map[string]string{},
1158 },
1159 {
1160 name: "valid map",
1161 input: map[string]any{
1162 "key1": "value1",
1163 "key2": "value2",
1164 },
1165 expected: map[string]string{
1166 "key1": "value1",
1167 "key2": "value2",
1168 },
1169 },
1170 {
1171 name: "invalid type",
1172 input: "not a map",
1173 expected: map[string]string{},
1174 },
1175 {
1176 name: "mixed values",
1177 input: map[string]any{
1178 "string": "value",
1179 "int": 123,
1180 },
1181 expected: map[string]string{
1182 "string": "value",
1183 },
1184 },
1185 }
1186
1187 for _, tt := range tests {
1188 t.Run(tt.name, func(t *testing.T) {
1189 result := loadStringMap(tt.input)
1190 assert.Equal(t, tt.expected, result)
1191 })
1192 }
1193}
1194
1195func TestLoadStringArrayMap(t *testing.T) {
1196 tests := []struct {
1197 name string
1198 input any
1199 expected map[string][]string
1200 }{
1201 {
1202 name: "nil input",
1203 input: nil,
1204 expected: map[string][]string{},
1205 },
1206 {
1207 name: "valid map",
1208 input: map[string]any{
1209 "group1": []any{"a", "b"},
1210 "group2": []any{"c", "d", "e"},
1211 },
1212 expected: map[string][]string{
1213 "group1": {"a", "b"},
1214 "group2": {"c", "d", "e"},
1215 },
1216 },
1217 {
1218 name: "invalid type",
1219 input: "not a map",
1220 expected: map[string][]string{},
1221 },
1222 {
1223 name: "non-array value",
1224 input: map[string]any{
1225 "array": []any{"a", "b"},
1226 "string": "not an array",
1227 },
1228 expected: map[string][]string{
1229 "array": {"a", "b"},
1230 },
1231 },
1232 }
1233
1234 for _, tt := range tests {
1235 t.Run(tt.name, func(t *testing.T) {
1236 result := loadStringArrayMap(tt.input)
1237 assert.Equal(t, tt.expected, result)
1238 })
1239 }
1240}