v2.31.0-dev8 ec8c5b9

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

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