v2.31.0-dev6 8056b57

service_test.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close