v2.31.0-dev22 8d342c4

service_test.go RawHistoryFolder

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

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 7

Tags 106

↑↓ moveenter openesc close