v2.10.0 893d8fa

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 TestExtractDomainFromImageName(t *testing.T) {
868	tests := []struct {
869		name          string
870		imageName     string
871		expectedDom   string
872		expectedFound bool
873	}{
874		{
875			name:          "domain like image",
876			imageName:     "myapp.example.com:latest",
877			expectedDom:   "myapp.example.com",
878			expectedFound: true,
879		},
880		{
881			name:          "domain like image without tag",
882			imageName:     "api.backend.io",
883			expectedDom:   "api.backend.io",
884			expectedFound: true,
885		},
886		{
887			name:          "simple image name",
888			imageName:     "nginx:latest",
889			expectedDom:   "",
890			expectedFound: false,
891		},
892		{
893			name:          "registry path",
894			imageName:     "gcr.io/project/image:tag",
895			expectedDom:   "gcr.io/project/image",
896			expectedFound: true,
897		},
898		{
899			name:          "leading dot",
900			imageName:     ".invalid:latest",
901			expectedDom:   "",
902			expectedFound: false,
903		},
904		{
905			name:          "trailing dot",
906			imageName:     "invalid.:latest",
907			expectedDom:   "",
908			expectedFound: false,
909		},
910	}
911
912	for _, tt := range tests {
913		t.Run(tt.name, func(t *testing.T) {
914			domain, found := ExtractDomainFromImageName(tt.imageName)
915			assert.Equal(t, tt.expectedDom, domain)
916			assert.Equal(t, tt.expectedFound, found)
917		})
918	}
919}
920
921func TestLoadStringMap(t *testing.T) {
922	tests := []struct {
923		name     string
924		input    any
925		expected map[string]string
926	}{
927		{
928			name:     "nil input",
929			input:    nil,
930			expected: map[string]string{},
931		},
932		{
933			name: "valid map",
934			input: map[string]any{
935				"key1": "value1",
936				"key2": "value2",
937			},
938			expected: map[string]string{
939				"key1": "value1",
940				"key2": "value2",
941			},
942		},
943		{
944			name:     "invalid type",
945			input:    "not a map",
946			expected: map[string]string{},
947		},
948		{
949			name: "mixed values",
950			input: map[string]any{
951				"string": "value",
952				"int":    123,
953			},
954			expected: map[string]string{
955				"string": "value",
956			},
957		},
958	}
959
960	for _, tt := range tests {
961		t.Run(tt.name, func(t *testing.T) {
962			result := loadStringMap(tt.input)
963			assert.Equal(t, tt.expected, result)
964		})
965	}
966}
967
968func TestLoadStringArrayMap(t *testing.T) {
969	tests := []struct {
970		name     string
971		input    any
972		expected map[string][]string
973	}{
974		{
975			name:     "nil input",
976			input:    nil,
977			expected: map[string][]string{},
978		},
979		{
980			name: "valid map",
981			input: map[string]any{
982				"group1": []any{"a", "b"},
983				"group2": []any{"c", "d", "e"},
984			},
985			expected: map[string][]string{
986				"group1": {"a", "b"},
987				"group2": {"c", "d", "e"},
988			},
989		},
990		{
991			name:     "invalid type",
992			input:    "not a map",
993			expected: map[string][]string{},
994		},
995		{
996			name: "non-array value",
997			input: map[string]any{
998				"array":  []any{"a", "b"},
999				"string": "not an array",
1000			},
1001			expected: map[string][]string{
1002				"array": {"a", "b"},
1003			},
1004		},
1005	}
1006
1007	for _, tt := range tests {
1008		t.Run(tt.name, func(t *testing.T) {
1009			result := loadStringArrayMap(tt.input)
1010			assert.Equal(t, tt.expected, result)
1011		})
1012	}
1013}

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