v2.16.0 ebd2ab3

service_test.go RawHistoryFolder

1package config
2
3import (
4	"context"
5	"os"
6	"path/filepath"
7	"testing"
8
9	"github.com/bnema/zerowrap"
10	"github.com/spf13/viper"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	"github.com/bnema/gordon/internal/boundaries/out/mocks"
15	"github.com/bnema/gordon/internal/domain"
16)
17
18func testContext() context.Context {
19	return zerowrap.WithCtx(context.Background(), zerowrap.Default())
20}
21
22func TestService_Load(t *testing.T) {
23	v := viper.New()
24	v.Set("server.port", 8080)
25	v.Set("server.registry_port", 5000)
26	v.Set("server.registry_domain", "registry.example.com")
27	v.Set("server.data_dir", "/var/gordon")
28	v.Set("auto_route.enabled", true)
29	v.Set("network_isolation.enabled", true)
30	v.Set("network_isolation.network_prefix", "gordon")
31	v.Set("auth.enabled", true)
32	v.Set("auth.username", "admin")
33	v.Set("auth.password", "secret")
34	v.Set("volumes.auto_create", true)
35	v.Set("volumes.prefix", "gordon")
36	v.Set("volumes.preserve", false)
37	v.Set("routes", map[string]interface{}{
38		"app.example.com": "myapp:latest",
39	})
40
41	eventBus := mocks.NewMockEventPublisher(t)
42	svc := NewService(v, eventBus)
43	ctx := testContext()
44
45	err := svc.Load(ctx)
46
47	assert.NoError(t, err)
48	assert.Equal(t, 8080, svc.GetServerPort())
49	assert.Equal(t, 5000, svc.GetRegistryPort())
50	assert.Equal(t, "registry.example.com", svc.GetRegistryDomain())
51	assert.Equal(t, "/var/gordon", svc.GetDataDir())
52	assert.True(t, svc.IsAutoRouteEnabled())
53	assert.True(t, svc.IsNetworkIsolationEnabled())
54	assert.Equal(t, "gordon", svc.GetNetworkPrefix())
55
56	enabled, username, password := svc.GetRegistryAuthConfig()
57	assert.True(t, enabled)
58	assert.Equal(t, "admin", username)
59	assert.Equal(t, "secret", password)
60
61	autoCreate, prefix, preserve := svc.GetVolumeConfig()
62	assert.True(t, autoCreate)
63	assert.Equal(t, "gordon", prefix)
64	assert.False(t, preserve)
65}
66
67func TestService_Reload(t *testing.T) {
68	t.Run("success - picks up config file changes", func(t *testing.T) {
69		// Create temp config file
70		tmpDir := t.TempDir()
71		configFile := filepath.Join(tmpDir, "gordon.toml")
72		initialConfig := `[server]
73port = 8080
74
75[routes]
76"app.example.com" = "myapp:v1"
77`
78		err := os.WriteFile(configFile, []byte(initialConfig), 0600)
79		require.NoError(t, err)
80
81		v := viper.New()
82		v.SetConfigFile(configFile)
83		err = v.ReadInConfig()
84		require.NoError(t, err)
85
86		eventBus := mocks.NewMockEventPublisher(t)
87		svc := NewService(v, eventBus)
88		ctx := testContext()
89
90		// Initial load
91		err = svc.Load(ctx)
92		require.NoError(t, err)
93		assert.Equal(t, 8080, svc.GetServerPort())
94		assert.Equal(t, "myapp:v1", svc.GetConfig().Routes["app.example.com"])
95
96		// Modify config file on disk
97		updatedConfig := `[server]
98port = 9090
99
100[routes]
101"app.example.com" = "myapp:v2"
102"new.example.com" = "newapp:latest"
103`
104		err = os.WriteFile(configFile, []byte(updatedConfig), 0600)
105		require.NoError(t, err)
106
107		// Reload should pick up new values
108		err = svc.Reload(ctx)
109		require.NoError(t, err)
110
111		assert.Equal(t, 9090, svc.GetServerPort())
112		assert.Equal(t, "myapp:v2", svc.GetConfig().Routes["app.example.com"])
113		assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
114	})
115
116	t.Run("error - config file not found", func(t *testing.T) {
117		tmpDir := t.TempDir()
118		configFile := filepath.Join(tmpDir, "gordon.toml")
119
120		// Create config, load it, then delete it
121		err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
122		require.NoError(t, err)
123
124		v := viper.New()
125		v.SetConfigFile(configFile)
126		err = v.ReadInConfig()
127		require.NoError(t, err)
128
129		eventBus := mocks.NewMockEventPublisher(t)
130		svc := NewService(v, eventBus)
131		ctx := testContext()
132
133		err = svc.Load(ctx)
134		require.NoError(t, err)
135
136		// Delete the config file
137		err = os.Remove(configFile)
138		require.NoError(t, err)
139
140		// Reload should fail
141		err = svc.Reload(ctx)
142		assert.Error(t, err)
143		assert.Contains(t, err.Error(), "failed to read config file")
144	})
145
146	t.Run("error - invalid config syntax", func(t *testing.T) {
147		tmpDir := t.TempDir()
148		configFile := filepath.Join(tmpDir, "gordon.toml")
149
150		// Create valid config initially
151		err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
152		require.NoError(t, err)
153
154		v := viper.New()
155		v.SetConfigFile(configFile)
156		err = v.ReadInConfig()
157		require.NoError(t, err)
158
159		eventBus := mocks.NewMockEventPublisher(t)
160		svc := NewService(v, eventBus)
161		ctx := testContext()
162
163		err = svc.Load(ctx)
164		require.NoError(t, err)
165
166		// Write invalid TOML
167		err = os.WriteFile(configFile, []byte("[server\nport = invalid syntax"), 0600)
168		require.NoError(t, err)
169
170		// Reload should fail
171		err = svc.Reload(ctx)
172		assert.Error(t, err)
173	})
174}
175
176func TestService_GetRoutes(t *testing.T) {
177	v := viper.New()
178	v.Set("routes", map[string]interface{}{
179		"app1.example.com":        "myapp:latest",
180		"app2.example.com":        "otherapp:v2",
181		"http://insecure.example": "insecureapp:latest",
182	})
183
184	eventBus := mocks.NewMockEventPublisher(t)
185	svc := NewService(v, eventBus)
186	ctx := testContext()
187
188	_ = svc.Load(ctx)
189
190	routes := svc.GetRoutes(ctx)
191
192	assert.Len(t, routes, 3)
193
194	// Check routes (order is not guaranteed)
195	routeMap := make(map[string]domain.Route)
196	for _, r := range routes {
197		routeMap[r.Domain] = r
198	}
199
200	assert.Equal(t, "myapp:latest", routeMap["app1.example.com"].Image)
201	assert.True(t, routeMap["app1.example.com"].HTTPS)
202
203	assert.Equal(t, "otherapp:v2", routeMap["app2.example.com"].Image)
204	assert.True(t, routeMap["app2.example.com"].HTTPS)
205
206	assert.Equal(t, "insecureapp:latest", routeMap["insecure.example"].Image)
207	assert.False(t, routeMap["insecure.example"].HTTPS)
208}
209
210func TestService_GetRoute(t *testing.T) {
211	v := viper.New()
212	v.Set("routes", map[string]interface{}{
213		"app.example.com":         "myapp:latest",
214		"http://insecure.example": "insecureapp:latest",
215	})
216
217	eventBus := mocks.NewMockEventPublisher(t)
218	svc := NewService(v, eventBus)
219	ctx := testContext()
220
221	_ = svc.Load(ctx)
222
223	t.Run("existing route", func(t *testing.T) {
224		route, err := svc.GetRoute(ctx, "app.example.com")
225		require.NoError(t, err)
226		assert.Equal(t, "app.example.com", route.Domain)
227		assert.Equal(t, "myapp:latest", route.Image)
228		assert.True(t, route.HTTPS)
229	})
230
231	t.Run("non-existent route", func(t *testing.T) {
232		route, err := svc.GetRoute(ctx, "notfound.example.com")
233		assert.ErrorIs(t, err, domain.ErrRouteNotFound)
234		assert.Nil(t, route)
235	})
236}
237
238func TestService_AddRoute(t *testing.T) {
239	t.Run("success", func(t *testing.T) {
240		// Create temp config file for Save() to work
241		tmpDir := t.TempDir()
242		configFile := filepath.Join(tmpDir, "gordon.toml")
243		err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
244		require.NoError(t, err)
245
246		v := viper.New()
247		v.SetConfigFile(configFile)
248		eventBus := mocks.NewMockEventPublisher(t)
249		svc := NewService(v, eventBus)
250		ctx := testContext()
251
252		_ = svc.Load(ctx)
253
254		route := domain.Route{
255			Domain: "new.example.com",
256			Image:  "newapp:latest",
257		}
258
259		err = svc.AddRoute(ctx, route)
260
261		assert.NoError(t, err)
262
263		// Verify route was added
264		config := svc.GetConfig()
265		assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
266	})
267
268	t.Run("empty domain", func(t *testing.T) {
269		v := viper.New()
270		eventBus := mocks.NewMockEventPublisher(t)
271		svc := NewService(v, eventBus)
272		ctx := testContext()
273
274		route := domain.Route{
275			Domain: "",
276			Image:  "myapp:latest",
277		}
278
279		err := svc.AddRoute(ctx, route)
280		assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
281	})
282
283	t.Run("empty image", func(t *testing.T) {
284		v := viper.New()
285		eventBus := mocks.NewMockEventPublisher(t)
286		svc := NewService(v, eventBus)
287		ctx := testContext()
288
289		route := domain.Route{
290			Domain: "example.com",
291			Image:  "",
292		}
293
294		err := svc.AddRoute(ctx, route)
295		assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
296	})
297}
298
299func TestService_UpdateRoute(t *testing.T) {
300	t.Run("success", func(t *testing.T) {
301		// Create temp config file for Save() to work
302		tmpDir := t.TempDir()
303		configFile := filepath.Join(tmpDir, "gordon.toml")
304		err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600)
305		require.NoError(t, err)
306
307		v := viper.New()
308		v.SetConfigFile(configFile)
309		v.Set("routes", map[string]any{
310			"app.example.com": "myapp:v1",
311		})
312
313		eventBus := mocks.NewMockEventPublisher(t)
314		svc := NewService(v, eventBus)
315		ctx := testContext()
316
317		_ = svc.Load(ctx)
318
319		route := domain.Route{
320			Domain: "app.example.com",
321			Image:  "myapp:v2",
322		}
323
324		err = svc.UpdateRoute(ctx, route)
325
326		assert.NoError(t, err)
327
328		config := svc.GetConfig()
329		assert.Equal(t, "myapp:v2", config.Routes["app.example.com"])
330	})
331
332	t.Run("not found", func(t *testing.T) {
333		v := viper.New()
334		eventBus := mocks.NewMockEventPublisher(t)
335		svc := NewService(v, eventBus)
336		ctx := testContext()
337
338		_ = svc.Load(ctx)
339
340		route := domain.Route{
341			Domain: "nonexistent.example.com",
342			Image:  "myapp:latest",
343		}
344
345		err := svc.UpdateRoute(ctx, route)
346		assert.ErrorIs(t, err, domain.ErrRouteNotFound)
347	})
348
349	t.Run("empty domain", func(t *testing.T) {
350		v := viper.New()
351		eventBus := mocks.NewMockEventPublisher(t)
352		svc := NewService(v, eventBus)
353		ctx := testContext()
354
355		route := domain.Route{
356			Domain: "",
357			Image:  "myapp:latest",
358		}
359
360		err := svc.UpdateRoute(ctx, route)
361		assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
362	})
363
364	t.Run("empty image", func(t *testing.T) {
365		v := viper.New()
366		eventBus := mocks.NewMockEventPublisher(t)
367		svc := NewService(v, eventBus)
368		ctx := testContext()
369
370		route := domain.Route{
371			Domain: "example.com",
372			Image:  "",
373		}
374
375		err := svc.UpdateRoute(ctx, route)
376		assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
377	})
378}
379
380func TestService_RemoveRoute(t *testing.T) {
381	// Create temp config file for Save() to work
382	tmpDir := t.TempDir()
383	configFile := filepath.Join(tmpDir, "gordon.toml")
384	err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
385	require.NoError(t, err)
386
387	v := viper.New()
388	v.SetConfigFile(configFile)
389	v.Set("routes", map[string]interface{}{
390		"app.example.com": "myapp:latest",
391	})
392
393	eventBus := mocks.NewMockEventPublisher(t)
394	svc := NewService(v, eventBus)
395	ctx := testContext()
396
397	_ = svc.Load(ctx)
398
399	err = svc.RemoveRoute(ctx, "app.example.com")
400
401	assert.NoError(t, err)
402
403	config := svc.GetConfig()
404	_, exists := config.Routes["app.example.com"]
405	assert.False(t, exists)
406}
407
408func TestService_RemoveRoute_NotFound(t *testing.T) {
409	v := viper.New()
410	eventBus := mocks.NewMockEventPublisher(t)
411	svc := NewService(v, eventBus)
412	ctx := testContext()
413
414	_ = svc.Load(ctx)
415
416	err := svc.RemoveRoute(ctx, "nonexistent.example.com")
417
418	assert.ErrorIs(t, err, domain.ErrRouteNotFound)
419}
420
421func TestService_GetNetworkGroups(t *testing.T) {
422	v := viper.New()
423	v.Set("network_groups", map[string]interface{}{
424		"frontend": []interface{}{"app1.example.com", "app2.example.com"},
425		"backend":  []interface{}{"api.example.com", "db.example.com"},
426	})
427
428	eventBus := mocks.NewMockEventPublisher(t)
429	svc := NewService(v, eventBus)
430	ctx := testContext()
431
432	_ = svc.Load(ctx)
433
434	groups := svc.GetNetworkGroups()
435
436	assert.Len(t, groups, 2)
437	assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
438	assert.ElementsMatch(t, []string{"api.example.com", "db.example.com"}, groups["backend"])
439}
440
441func TestService_GetAttachments(t *testing.T) {
442	v := viper.New()
443	v.Set("attachments", map[string]interface{}{
444		"app.example.com": []interface{}{"redis:latest", "postgres:18"},
445	})
446
447	eventBus := mocks.NewMockEventPublisher(t)
448	svc := NewService(v, eventBus)
449	ctx := testContext()
450
451	_ = svc.Load(ctx)
452
453	attachments := svc.GetAttachments()
454
455	assert.Len(t, attachments, 1)
456	assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
457}
458
459func TestService_GetAllAttachments(t *testing.T) {
460	t.Run("returns all attachments", func(t *testing.T) {
461		v := viper.New()
462		v.Set("attachments", map[string]interface{}{
463			"app.example.com": []interface{}{"redis:latest", "postgres:18"},
464			"api.example.com": []interface{}{"rabbitmq:3"},
465		})
466
467		eventBus := mocks.NewMockEventPublisher(t)
468		svc := NewService(v, eventBus)
469		ctx := testContext()
470
471		_ = svc.Load(ctx)
472
473		attachments := svc.GetAllAttachments(ctx)
474
475		assert.Len(t, attachments, 2)
476		assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
477		assert.ElementsMatch(t, []string{"rabbitmq:3"}, attachments["api.example.com"])
478	})
479
480	t.Run("returns empty map when no attachments", func(t *testing.T) {
481		v := viper.New()
482		eventBus := mocks.NewMockEventPublisher(t)
483		svc := NewService(v, eventBus)
484		ctx := testContext()
485
486		_ = svc.Load(ctx)
487
488		attachments := svc.GetAllAttachments(ctx)
489
490		assert.Empty(t, attachments)
491	})
492
493	t.Run("returns copy not reference", func(t *testing.T) {
494		v := viper.New()
495		v.Set("attachments", map[string]interface{}{
496			"app.example.com": []interface{}{"redis:latest"},
497		})
498
499		eventBus := mocks.NewMockEventPublisher(t)
500		svc := NewService(v, eventBus)
501		ctx := testContext()
502
503		_ = svc.Load(ctx)
504
505		attachments := svc.GetAllAttachments(ctx)
506		// Modify the returned map
507		attachments["app.example.com"] = append(attachments["app.example.com"], "postgres:18")
508
509		// Original should be unchanged
510		original := svc.GetAllAttachments(ctx)
511		assert.Len(t, original["app.example.com"], 1)
512	})
513}
514
515func TestService_GetAttachmentsFor(t *testing.T) {
516	t.Run("existing domain", func(t *testing.T) {
517		v := viper.New()
518		v.Set("attachments", map[string]interface{}{
519			"app.example.com": []interface{}{"redis:latest", "postgres:18"},
520		})
521
522		eventBus := mocks.NewMockEventPublisher(t)
523		svc := NewService(v, eventBus)
524		ctx := testContext()
525
526		_ = svc.Load(ctx)
527
528		images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
529
530		require.NoError(t, err)
531		assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, images)
532	})
533
534	t.Run("existing network group", func(t *testing.T) {
535		v := viper.New()
536		v.Set("attachments", map[string]interface{}{
537			"backend": []interface{}{"rabbitmq:3", "redis:latest"},
538		})
539
540		eventBus := mocks.NewMockEventPublisher(t)
541		svc := NewService(v, eventBus)
542		ctx := testContext()
543
544		_ = svc.Load(ctx)
545
546		images, err := svc.GetAttachmentsFor(ctx, "backend")
547
548		require.NoError(t, err)
549		assert.ElementsMatch(t, []string{"rabbitmq:3", "redis:latest"}, images)
550	})
551
552	t.Run("non-existent target", func(t *testing.T) {
553		v := viper.New()
554		eventBus := mocks.NewMockEventPublisher(t)
555		svc := NewService(v, eventBus)
556		ctx := testContext()
557
558		_ = svc.Load(ctx)
559
560		images, err := svc.GetAttachmentsFor(ctx, "notfound.example.com")
561
562		assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
563		assert.Nil(t, images)
564	})
565
566	t.Run("returns copy not reference", func(t *testing.T) {
567		v := viper.New()
568		v.Set("attachments", map[string]interface{}{
569			"app.example.com": []interface{}{"redis:latest"},
570		})
571
572		eventBus := mocks.NewMockEventPublisher(t)
573		svc := NewService(v, eventBus)
574		ctx := testContext()
575
576		_ = svc.Load(ctx)
577
578		images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
579		require.NoError(t, err)
580
581		// Modify the returned slice (use _ to satisfy linter)
582		_ = append(images, "postgres:18")
583
584		// Original should be unchanged
585		original, _ := svc.GetAttachmentsFor(ctx, "app.example.com")
586		assert.Len(t, original, 1)
587	})
588}
589
590func TestService_AddAttachment(t *testing.T) {
591	t.Run("success - new target", func(t *testing.T) {
592		tmpDir := t.TempDir()
593		configFile := filepath.Join(tmpDir, "gordon.toml")
594		err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
595		require.NoError(t, err)
596
597		v := viper.New()
598		v.SetConfigFile(configFile)
599		eventBus := mocks.NewMockEventPublisher(t)
600		svc := NewService(v, eventBus)
601		ctx := testContext()
602
603		_ = svc.Load(ctx)
604
605		err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
606
607		assert.NoError(t, err)
608
609		// Verify attachment was added
610		config := svc.GetConfig()
611		assert.Contains(t, config.Attachments["app.example.com"], "postgres:18")
612	})
613
614	t.Run("success - existing target", func(t *testing.T) {
615		tmpDir := t.TempDir()
616		configFile := filepath.Join(tmpDir, "gordon.toml")
617		err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
618		require.NoError(t, err)
619
620		v := viper.New()
621		v.SetConfigFile(configFile)
622		v.Set("attachments", map[string]interface{}{
623			"app.example.com": []interface{}{"redis:latest"},
624		})
625		eventBus := mocks.NewMockEventPublisher(t)
626		svc := NewService(v, eventBus)
627		ctx := testContext()
628
629		_ = svc.Load(ctx)
630
631		err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
632
633		assert.NoError(t, err)
634
635		// Verify attachment was added alongside existing
636		config := svc.GetConfig()
637		assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
638	})
639
640	t.Run("duplicate attachment", func(t *testing.T) {
641		v := viper.New()
642		v.Set("attachments", map[string]interface{}{
643			"app.example.com": []interface{}{"redis:latest"},
644		})
645		eventBus := mocks.NewMockEventPublisher(t)
646		svc := NewService(v, eventBus)
647		ctx := testContext()
648
649		_ = svc.Load(ctx)
650
651		err := svc.AddAttachment(ctx, "app.example.com", "redis:latest")
652
653		assert.ErrorIs(t, err, domain.ErrAttachmentExists)
654	})
655
656	t.Run("empty target", func(t *testing.T) {
657		v := viper.New()
658		eventBus := mocks.NewMockEventPublisher(t)
659		svc := NewService(v, eventBus)
660		ctx := testContext()
661
662		err := svc.AddAttachment(ctx, "", "postgres:18")
663
664		assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
665	})
666
667	t.Run("empty image", func(t *testing.T) {
668		v := viper.New()
669		eventBus := mocks.NewMockEventPublisher(t)
670		svc := NewService(v, eventBus)
671		ctx := testContext()
672
673		err := svc.AddAttachment(ctx, "app.example.com", "")
674
675		assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
676	})
677
678	t.Run("network group target", func(t *testing.T) {
679		tmpDir := t.TempDir()
680		configFile := filepath.Join(tmpDir, "gordon.toml")
681		err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
682		require.NoError(t, err)
683
684		v := viper.New()
685		v.SetConfigFile(configFile)
686		eventBus := mocks.NewMockEventPublisher(t)
687		svc := NewService(v, eventBus)
688		ctx := testContext()
689
690		_ = svc.Load(ctx)
691
692		err = svc.AddAttachment(ctx, "backend", "rabbitmq:3")
693
694		assert.NoError(t, err)
695
696		config := svc.GetConfig()
697		assert.Contains(t, config.Attachments["backend"], "rabbitmq:3")
698	})
699}
700
701func TestService_RemoveAttachment(t *testing.T) {
702	t.Run("success - single attachment", func(t *testing.T) {
703		tmpDir := t.TempDir()
704		configFile := filepath.Join(tmpDir, "gordon.toml")
705		err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
706		require.NoError(t, err)
707
708		v := viper.New()
709		v.SetConfigFile(configFile)
710		v.Set("attachments", map[string]interface{}{
711			"app.example.com": []interface{}{"redis:latest"},
712		})
713		eventBus := mocks.NewMockEventPublisher(t)
714		svc := NewService(v, eventBus)
715		ctx := testContext()
716
717		_ = svc.Load(ctx)
718
719		err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
720
721		assert.NoError(t, err)
722
723		// Verify target was removed (no attachments left)
724		config := svc.GetConfig()
725		_, exists := config.Attachments["app.example.com"]
726		assert.False(t, exists)
727	})
728
729	t.Run("success - multiple attachments", func(t *testing.T) {
730		tmpDir := t.TempDir()
731		configFile := filepath.Join(tmpDir, "gordon.toml")
732		err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\", \"postgres:18\"]\n"), 0600)
733		require.NoError(t, err)
734
735		v := viper.New()
736		v.SetConfigFile(configFile)
737		v.Set("attachments", map[string]interface{}{
738			"app.example.com": []interface{}{"redis:latest", "postgres:18"},
739		})
740		eventBus := mocks.NewMockEventPublisher(t)
741		svc := NewService(v, eventBus)
742		ctx := testContext()
743
744		_ = svc.Load(ctx)
745
746		err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
747
748		assert.NoError(t, err)
749
750		// Verify only redis was removed
751		config := svc.GetConfig()
752		assert.Equal(t, []string{"postgres:18"}, config.Attachments["app.example.com"])
753	})
754
755	t.Run("target not found", func(t *testing.T) {
756		v := viper.New()
757		eventBus := mocks.NewMockEventPublisher(t)
758		svc := NewService(v, eventBus)
759		ctx := testContext()
760
761		_ = svc.Load(ctx)
762
763		err := svc.RemoveAttachment(ctx, "notfound.example.com", "redis:latest")
764
765		assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
766	})
767
768	t.Run("image not found in target", func(t *testing.T) {
769		v := viper.New()
770		v.Set("attachments", map[string]interface{}{
771			"app.example.com": []interface{}{"redis:latest"},
772		})
773		eventBus := mocks.NewMockEventPublisher(t)
774		svc := NewService(v, eventBus)
775		ctx := testContext()
776
777		_ = svc.Load(ctx)
778
779		err := svc.RemoveAttachment(ctx, "app.example.com", "postgres:18")
780
781		assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
782	})
783
784	t.Run("empty target", func(t *testing.T) {
785		v := viper.New()
786		eventBus := mocks.NewMockEventPublisher(t)
787		svc := NewService(v, eventBus)
788		ctx := testContext()
789
790		err := svc.RemoveAttachment(ctx, "", "redis:latest")
791
792		assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
793	})
794
795	t.Run("empty image", func(t *testing.T) {
796		v := viper.New()
797		eventBus := mocks.NewMockEventPublisher(t)
798		svc := NewService(v, eventBus)
799		ctx := testContext()
800
801		err := svc.RemoveAttachment(ctx, "app.example.com", "")
802
803		assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
804	})
805
806	t.Run("network group target", func(t *testing.T) {
807		tmpDir := t.TempDir()
808		configFile := filepath.Join(tmpDir, "gordon.toml")
809		err := os.WriteFile(configFile, []byte("[attachments]\n\"backend\" = [\"rabbitmq:3\"]\n"), 0600)
810		require.NoError(t, err)
811
812		v := viper.New()
813		v.SetConfigFile(configFile)
814		v.Set("attachments", map[string]interface{}{
815			"backend": []interface{}{"rabbitmq:3"},
816		})
817		eventBus := mocks.NewMockEventPublisher(t)
818		svc := NewService(v, eventBus)
819		ctx := testContext()
820
821		_ = svc.Load(ctx)
822
823		err = svc.RemoveAttachment(ctx, "backend", "rabbitmq:3")
824
825		assert.NoError(t, err)
826
827		config := svc.GetConfig()
828		_, exists := config.Attachments["backend"]
829		assert.False(t, exists)
830	})
831}
832
833func TestService_GetExternalRoutes(t *testing.T) {
834	v := viper.New()
835	v.Set("external_routes", map[string]interface{}{
836		"reg.example.com":   "localhost:5000",
837		"cache.example.com": "127.0.0.1:6379",
838	})
839
840	eventBus := mocks.NewMockEventPublisher(t)
841	svc := NewService(v, eventBus)
842	ctx := testContext()
843
844	_ = svc.Load(ctx)
845
846	routes := svc.GetExternalRoutes()
847
848	assert.Len(t, routes, 2)
849	assert.Equal(t, "localhost:5000", routes["reg.example.com"])
850	assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
851}
852
853func TestService_GetExternalRoutes_Empty(t *testing.T) {
854	v := viper.New()
855
856	eventBus := mocks.NewMockEventPublisher(t)
857	svc := NewService(v, eventBus)
858	ctx := testContext()
859
860	_ = svc.Load(ctx)
861
862	routes := svc.GetExternalRoutes()
863
864	assert.Empty(t, routes)
865}
866
867func TestSplitImageNameTag(t *testing.T) {
868	tests := []struct {
869		name       string
870		image      string
871		wantName   string
872		wantHasTag bool
873	}{
874		{"with tag", "myapp:latest", "myapp", true},
875		{"with version tag", "myapp:v1.2.0", "myapp", true},
876		{"no tag", "myapp", "myapp", false},
877		{"empty string", "", "", false},
878		{"tag only colon", "myapp:", "myapp", true},
879		{"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true},
880		{"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false},
881	}
882
883	for _, tt := range tests {
884		t.Run(tt.name, func(t *testing.T) {
885			name, hasTag := splitImageNameTag(tt.image)
886			assert.Equal(t, tt.wantName, name)
887			assert.Equal(t, tt.wantHasTag, hasTag)
888		})
889	}
890}
891
892func TestNormalizeRegistryImage(t *testing.T) {
893	tests := []struct {
894		name           string
895		imageName      string
896		registryDomain string
897		expected       string
898	}{
899		{"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
900		{"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
901		{"empty registry domain", "myapp:latest", "", "myapp:latest"},
902		{"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
903		{"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
904		{"bare name no registry", "myapp", "reg.example.com", "myapp"},
905		{"exact registry without image", "reg.example.com/", "reg.example.com", ""},
906	}
907
908	for _, tt := range tests {
909		t.Run(tt.name, func(t *testing.T) {
910			result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
911			assert.Equal(t, tt.expected, result)
912		})
913	}
914}
915
916func TestService_FindRoutesByImage(t *testing.T) {
917	t.Run("exact match with tag", func(t *testing.T) {
918		v := viper.New()
919		v.Set("routes", map[string]interface{}{
920			"app.example.com": "myapp:latest",
921		})
922
923		eventBus := mocks.NewMockEventPublisher(t)
924		svc := NewService(v, eventBus)
925		ctx := testContext()
926		_ = svc.Load(ctx)
927
928		routes := svc.FindRoutesByImage(ctx, "myapp:latest")
929		assert.Len(t, routes, 1)
930		assert.Equal(t, "app.example.com", routes[0].Domain)
931		assert.Equal(t, "myapp:latest", routes[0].Image)
932	})
933
934	t.Run("bare name matches route with tag", func(t *testing.T) {
935		v := viper.New()
936		v.Set("routes", map[string]interface{}{
937			"app.example.com": "myapp:latest",
938		})
939
940		eventBus := mocks.NewMockEventPublisher(t)
941		svc := NewService(v, eventBus)
942		ctx := testContext()
943		_ = svc.Load(ctx)
944
945		routes := svc.FindRoutesByImage(ctx, "myapp")
946		assert.Len(t, routes, 1)
947		assert.Equal(t, "app.example.com", routes[0].Domain)
948	})
949
950	t.Run("bare name matches route with version tag", func(t *testing.T) {
951		v := viper.New()
952		v.Set("routes", map[string]interface{}{
953			"app.example.com": "myapp:v2.0.0",
954		})
955
956		eventBus := mocks.NewMockEventPublisher(t)
957		svc := NewService(v, eventBus)
958		ctx := testContext()
959		_ = svc.Load(ctx)
960
961		routes := svc.FindRoutesByImage(ctx, "myapp")
962		assert.Len(t, routes, 1)
963		assert.Equal(t, "app.example.com", routes[0].Domain)
964	})
965
966	t.Run("tag mismatch does not match", func(t *testing.T) {
967		v := viper.New()
968		v.Set("routes", map[string]interface{}{
969			"app.example.com": "myapp:v1",
970		})
971
972		eventBus := mocks.NewMockEventPublisher(t)
973		svc := NewService(v, eventBus)
974		ctx := testContext()
975		_ = svc.Load(ctx)
976
977		routes := svc.FindRoutesByImage(ctx, "myapp:v2")
978		assert.Empty(t, routes)
979	})
980
981	t.Run("no match", func(t *testing.T) {
982		v := viper.New()
983		v.Set("routes", map[string]interface{}{
984			"app.example.com": "myapp:latest",
985		})
986
987		eventBus := mocks.NewMockEventPublisher(t)
988		svc := NewService(v, eventBus)
989		ctx := testContext()
990		_ = svc.Load(ctx)
991
992		routes := svc.FindRoutesByImage(ctx, "otherapp")
993		assert.Empty(t, routes)
994	})
995
996	t.Run("strips registry prefix before matching", func(t *testing.T) {
997		v := viper.New()
998		v.Set("server.registry_domain", "reg.example.com")
999		v.Set("routes", map[string]interface{}{
1000			"app.example.com": "reg.example.com/myapp:latest",
1001		})
1002
1003		eventBus := mocks.NewMockEventPublisher(t)
1004		svc := NewService(v, eventBus)
1005		ctx := testContext()
1006		_ = svc.Load(ctx)
1007
1008		routes := svc.FindRoutesByImage(ctx, "myapp")
1009		assert.Len(t, routes, 1)
1010		assert.Equal(t, "app.example.com", routes[0].Domain)
1011	})
1012
1013	t.Run("strips registry prefix from input too", func(t *testing.T) {
1014		v := viper.New()
1015		v.Set("server.registry_domain", "reg.example.com")
1016		v.Set("routes", map[string]interface{}{
1017			"app.example.com": "reg.example.com/myapp:latest",
1018		})
1019
1020		eventBus := mocks.NewMockEventPublisher(t)
1021		svc := NewService(v, eventBus)
1022		ctx := testContext()
1023		_ = svc.Load(ctx)
1024
1025		routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest")
1026		assert.Len(t, routes, 1)
1027		assert.Equal(t, "app.example.com", routes[0].Domain)
1028	})
1029
1030	t.Run("multiple routes for same image", func(t *testing.T) {
1031		v := viper.New()
1032		v.Set("routes", map[string]interface{}{
1033			"app1.example.com":  "myapp:latest",
1034			"app2.example.com":  "myapp:latest",
1035			"other.example.com": "otherapp:latest",
1036		})
1037
1038		eventBus := mocks.NewMockEventPublisher(t)
1039		svc := NewService(v, eventBus)
1040		ctx := testContext()
1041		_ = svc.Load(ctx)
1042
1043		routes := svc.FindRoutesByImage(ctx, "myapp")
1044		assert.Len(t, routes, 2)
1045
1046		domains := []string{routes[0].Domain, routes[1].Domain}
1047		assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains)
1048	})
1049
1050	t.Run("case insensitive match", func(t *testing.T) {
1051		v := viper.New()
1052		v.Set("routes", map[string]interface{}{
1053			"app.example.com": "MyApp:latest",
1054		})
1055
1056		eventBus := mocks.NewMockEventPublisher(t)
1057		svc := NewService(v, eventBus)
1058		ctx := testContext()
1059		_ = svc.Load(ctx)
1060
1061		routes := svc.FindRoutesByImage(ctx, "myapp")
1062		assert.Len(t, routes, 1)
1063	})
1064
1065	t.Run("http prefix route sets HTTPS false", func(t *testing.T) {
1066		v := viper.New()
1067		v.Set("routes", map[string]interface{}{
1068			"http://insecure.example.com": "myapp:latest",
1069		})
1070
1071		eventBus := mocks.NewMockEventPublisher(t)
1072		svc := NewService(v, eventBus)
1073		ctx := testContext()
1074		_ = svc.Load(ctx)
1075
1076		routes := svc.FindRoutesByImage(ctx, "myapp")
1077		assert.Len(t, routes, 1)
1078		assert.Equal(t, "insecure.example.com", routes[0].Domain)
1079		assert.False(t, routes[0].HTTPS)
1080	})
1081
1082	t.Run("empty routes", func(t *testing.T) {
1083		v := viper.New()
1084		eventBus := mocks.NewMockEventPublisher(t)
1085		svc := NewService(v, eventBus)
1086		ctx := testContext()
1087		_ = svc.Load(ctx)
1088
1089		routes := svc.FindRoutesByImage(ctx, "myapp")
1090		assert.Empty(t, routes)
1091	})
1092}
1093
1094func TestExtractDomainFromImageName(t *testing.T) {
1095	tests := []struct {
1096		name          string
1097		imageName     string
1098		expectedDom   string
1099		expectedFound bool
1100	}{
1101		{
1102			name:          "domain like image",
1103			imageName:     "myapp.example.com:latest",
1104			expectedDom:   "myapp.example.com",
1105			expectedFound: true,
1106		},
1107		{
1108			name:          "domain like image without tag",
1109			imageName:     "api.backend.io",
1110			expectedDom:   "api.backend.io",
1111			expectedFound: true,
1112		},
1113		{
1114			name:          "simple image name",
1115			imageName:     "nginx:latest",
1116			expectedDom:   "",
1117			expectedFound: false,
1118		},
1119		{
1120			name:          "registry path",
1121			imageName:     "gcr.io/project/image:tag",
1122			expectedDom:   "gcr.io/project/image",
1123			expectedFound: true,
1124		},
1125		{
1126			name:          "leading dot",
1127			imageName:     ".invalid:latest",
1128			expectedDom:   "",
1129			expectedFound: false,
1130		},
1131		{
1132			name:          "trailing dot",
1133			imageName:     "invalid.:latest",
1134			expectedDom:   "",
1135			expectedFound: false,
1136		},
1137	}
1138
1139	for _, tt := range tests {
1140		t.Run(tt.name, func(t *testing.T) {
1141			domain, found := ExtractDomainFromImageName(tt.imageName)
1142			assert.Equal(t, tt.expectedDom, domain)
1143			assert.Equal(t, tt.expectedFound, found)
1144		})
1145	}
1146}
1147
1148func TestLoadStringMap(t *testing.T) {
1149	tests := []struct {
1150		name     string
1151		input    any
1152		expected map[string]string
1153	}{
1154		{
1155			name:     "nil input",
1156			input:    nil,
1157			expected: map[string]string{},
1158		},
1159		{
1160			name: "valid map",
1161			input: map[string]any{
1162				"key1": "value1",
1163				"key2": "value2",
1164			},
1165			expected: map[string]string{
1166				"key1": "value1",
1167				"key2": "value2",
1168			},
1169		},
1170		{
1171			name:     "invalid type",
1172			input:    "not a map",
1173			expected: map[string]string{},
1174		},
1175		{
1176			name: "mixed values",
1177			input: map[string]any{
1178				"string": "value",
1179				"int":    123,
1180			},
1181			expected: map[string]string{
1182				"string": "value",
1183			},
1184		},
1185	}
1186
1187	for _, tt := range tests {
1188		t.Run(tt.name, func(t *testing.T) {
1189			result := loadStringMap(tt.input)
1190			assert.Equal(t, tt.expected, result)
1191		})
1192	}
1193}
1194
1195func TestLoadStringArrayMap(t *testing.T) {
1196	tests := []struct {
1197		name     string
1198		input    any
1199		expected map[string][]string
1200	}{
1201		{
1202			name:     "nil input",
1203			input:    nil,
1204			expected: map[string][]string{},
1205		},
1206		{
1207			name: "valid map",
1208			input: map[string]any{
1209				"group1": []any{"a", "b"},
1210				"group2": []any{"c", "d", "e"},
1211			},
1212			expected: map[string][]string{
1213				"group1": {"a", "b"},
1214				"group2": {"c", "d", "e"},
1215			},
1216		},
1217		{
1218			name:     "invalid type",
1219			input:    "not a map",
1220			expected: map[string][]string{},
1221		},
1222		{
1223			name: "non-array value",
1224			input: map[string]any{
1225				"array":  []any{"a", "b"},
1226				"string": "not an array",
1227			},
1228			expected: map[string][]string{
1229				"array": {"a", "b"},
1230			},
1231		},
1232	}
1233
1234	for _, tt := range tests {
1235		t.Run(tt.name, func(t *testing.T) {
1236			result := loadStringArrayMap(tt.input)
1237			assert.Equal(t, tt.expected, result)
1238		})
1239	}
1240}

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