v2.31.0-dev2 4b72742

service_test.go RawHistoryFolder

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

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