v2.31.0-dev9 062dcae

service_test.go RawHistoryFolder

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

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