v2.31.0-dev21 b59533b

autoroute_test.go RawHistoryFolder

1package container
2
3import (
4	"context"
5	"encoding/json"
6	"io"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11
12	"github.com/bnema/zerowrap"
13	"github.com/spf13/viper"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17
18	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
19	"github.com/bnema/gordon/internal/boundaries/out/mocks"
20	"github.com/bnema/gordon/internal/domain"
21	"github.com/bnema/gordon/internal/usecase/auto"
22	configusecase "github.com/bnema/gordon/internal/usecase/config"
23)
24
25// domainToEnvFileName tests
26
27func TestDomainToEnvFileName(t *testing.T) {
28	tests := []struct {
29		name     string
30		domain   string
31		expected string
32	}{
33		{
34			name:     "simple domain",
35			domain:   "app.example.com",
36			expected: "YXBwLmV4YW1wbGUuY29t.env",
37		},
38		{
39			name:     "domain with port",
40			domain:   "app.example.com:8080",
41			expected: "YXBwLmV4YW1wbGUuY29tOjgwODA.env",
42		},
43		{
44			name:     "domain with path",
45			domain:   "app.example.com/api",
46			expected: "YXBwLmV4YW1wbGUuY29tL2FwaQ.env",
47		},
48		{
49			name:     "complex domain",
50			domain:   "sub.app.example.com:443/v1",
51			expected: "c3ViLmFwcC5leGFtcGxlLmNvbTo0NDMvdjE.env",
52		},
53		{
54			name:     "single word",
55			domain:   "localhost",
56			expected: "bG9jYWxob3N0.env",
57		},
58	}
59
60	for _, tt := range tests {
61		t.Run(tt.name, func(t *testing.T) {
62			result, err := domainToEnvFileName(tt.domain)
63			require.NoError(t, err)
64			assert.Equal(t, tt.expected, result)
65		})
66	}
67}
68
69// ParseEnvData tests
70
71func TestParseEnvFile(t *testing.T) {
72	tests := []struct {
73		name     string
74		input    string
75		expected map[string]string
76		wantErr  bool
77	}{
78		{
79			name:  "simple key-value pairs",
80			input: "FOO=bar\nBAZ=qux",
81			expected: map[string]string{
82				"FOO": "bar",
83				"BAZ": "qux",
84			},
85		},
86		{
87			name:  "with comments and empty lines",
88			input: "# This is a comment\nFOO=bar\n\n# Another comment\nBAZ=qux\n",
89			expected: map[string]string{
90				"FOO": "bar",
91				"BAZ": "qux",
92			},
93		},
94		{
95			name:  "with quoted values - double quotes",
96			input: `FOO="bar baz"`,
97			expected: map[string]string{
98				"FOO": "bar baz",
99			},
100		},
101		{
102			name:  "with quoted values - single quotes",
103			input: `FOO='bar baz'`,
104			expected: map[string]string{
105				"FOO": "bar baz",
106			},
107		},
108		{
109			name:  "value with equals sign",
110			input: "DATABASE_URL=postgres://user:pass@host/db?option=value",
111			expected: map[string]string{
112				"DATABASE_URL": "postgres://user:pass@host/db?option=value",
113			},
114		},
115		{
116			name:  "empty value",
117			input: "EMPTY=",
118			expected: map[string]string{
119				"EMPTY": "",
120			},
121		},
122		{
123			name:     "empty file",
124			input:    "",
125			expected: map[string]string{},
126		},
127		{
128			name:     "only comments",
129			input:    "# comment 1\n# comment 2",
130			expected: map[string]string{},
131		},
132		{
133			name:     "malformed line without equals",
134			input:    "MALFORMED\nGOOD=value",
135			expected: map[string]string{"GOOD": "value"},
136		},
137		{
138			name:  "whitespace trimming",
139			input: "  FOO  =  bar  \n  BAZ=qux",
140			expected: map[string]string{
141				"FOO": "bar",
142				"BAZ": "qux",
143			},
144		},
145	}
146
147	for _, tt := range tests {
148		t.Run(tt.name, func(t *testing.T) {
149			result, err := domain.ParseEnvData([]byte(tt.input))
150
151			if tt.wantErr {
152				assert.Error(t, err)
153				return
154			}
155
156			require.NoError(t, err)
157			assert.Equal(t, tt.expected, result)
158		})
159	}
160}
161
162// parseConfigDigest tests — now delegated to auto package; tested there directly.
163// These tests call auto.ParseConfigDigest to maintain coverage for this package.
164
165func TestParseConfigDigest(t *testing.T) {
166	tests := []struct {
167		name     string
168		manifest map[string]any
169		expected string
170		wantErr  bool
171	}{
172		{
173			name: "valid manifest v2",
174			manifest: map[string]any{
175				"schemaVersion": 2,
176				"mediaType":     "application/vnd.docker.distribution.manifest.v2+json",
177				"config": map[string]any{
178					"mediaType": "application/vnd.docker.container.image.v1+json",
179					"digest":    "sha256:abc123",
180					"size":      1234,
181				},
182			},
183			expected: "sha256:abc123",
184		},
185		{
186			name: "OCI manifest",
187			manifest: map[string]any{
188				"schemaVersion": 2,
189				"mediaType":     "application/vnd.oci.image.manifest.v1+json",
190				"config": map[string]any{
191					"mediaType": "application/vnd.oci.image.config.v1+json",
192					"digest":    "sha256:def456",
193					"size":      5678,
194				},
195			},
196			expected: "sha256:def456",
197		},
198		{
199			name: "empty config digest",
200			manifest: map[string]any{
201				"schemaVersion": 2,
202				"config": map[string]any{
203					"digest": "",
204				},
205			},
206			expected: "",
207		},
208	}
209
210	for _, tt := range tests {
211		t.Run(tt.name, func(t *testing.T) {
212			data, err := json.Marshal(tt.manifest)
213			require.NoError(t, err)
214
215			result, err := auto.ParseConfigDigest(data)
216
217			if tt.wantErr {
218				assert.Error(t, err)
219				return
220			}
221
222			require.NoError(t, err)
223			assert.Equal(t, tt.expected, result)
224		})
225	}
226}
227
228func TestParseConfigDigest_InvalidJSON(t *testing.T) {
229	_, err := auto.ParseConfigDigest([]byte("invalid json"))
230	assert.Error(t, err)
231}
232
233// parseImageLabels tests — now delegated to auto package; tested there directly.
234
235func TestParseImageLabels(t *testing.T) {
236	tests := []struct {
237		name     string
238		config   map[string]any
239		expected *domain.ImageLabels
240	}{
241		{
242			name: "single domain label",
243			config: map[string]any{
244				"config": map[string]any{
245					"Labels": map[string]any{
246						"gordon.domain": "app.example.com",
247					},
248				},
249			},
250			expected: &domain.ImageLabels{
251				Domain: "app.example.com",
252			},
253		},
254		{
255			name: "multiple domains label",
256			config: map[string]any{
257				"config": map[string]any{
258					"Labels": map[string]any{
259						"gordon.domains": "app1.example.com, app2.example.com",
260					},
261				},
262			},
263			expected: &domain.ImageLabels{
264				Domains: []string{"app1.example.com", "app2.example.com"},
265			},
266		},
267		{
268			name: "all gordon labels",
269			config: map[string]any{
270				"config": map[string]any{
271					"Labels": map[string]any{
272						"gordon.domain":     "app.example.com",
273						"gordon.health":     "/healthz",
274						"gordon.proxy.port": "8080",
275						"gordon.env-file":   ".env.prod",
276					},
277				},
278			},
279			expected: &domain.ImageLabels{
280				Domain:  "app.example.com",
281				Health:  "/healthz",
282				Port:    "8080",
283				EnvFile: ".env.prod",
284			},
285		},
286		{
287			name: "no labels",
288			config: map[string]any{
289				"config": map[string]any{
290					"Labels": nil,
291				},
292			},
293			expected: &domain.ImageLabels{},
294		},
295		{
296			name: "no gordon labels",
297			config: map[string]any{
298				"config": map[string]any{
299					"Labels": map[string]any{
300						"other.label": "value",
301					},
302				},
303			},
304			expected: &domain.ImageLabels{},
305		},
306		{
307			name: "whitespace in labels",
308			config: map[string]any{
309				"config": map[string]any{
310					"Labels": map[string]any{
311						"gordon.domain": "  app.example.com  ",
312					},
313				},
314			},
315			expected: &domain.ImageLabels{
316				Domain: "app.example.com",
317			},
318		},
319	}
320
321	for _, tt := range tests {
322		t.Run(tt.name, func(t *testing.T) {
323			data, err := json.Marshal(tt.config)
324			require.NoError(t, err)
325
326			result, err := auto.ParseImageLabels(data)
327			require.NoError(t, err)
328
329			assert.Equal(t, tt.expected.Domain, result.Domain)
330			assert.Equal(t, tt.expected.Domains, result.Domains)
331			assert.Equal(t, tt.expected.Health, result.Health)
332			assert.Equal(t, tt.expected.Port, result.Port)
333			assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
334		})
335	}
336}
337
338func TestParseImageLabels_InvalidJSON(t *testing.T) {
339	_, err := auto.ParseImageLabels([]byte("invalid json"))
340	assert.Error(t, err)
341}
342
343func TestMatchesDomainAllowlist(t *testing.T) {
344	tests := []struct {
345		name     string
346		domain   string
347		patterns []string
348		want     bool
349	}{
350		{name: "exact match", domain: "app.example.com", patterns: []string{"app.example.com"}, want: true},
351		{name: "no match", domain: "app.example.com", patterns: []string{"api.example.com"}, want: false},
352		{name: "wildcard match", domain: "app.example.com", patterns: []string{"*.example.com"}, want: true},
353		{name: "wildcard no match on root", domain: "example.com", patterns: []string{"*.example.com"}, want: false},
354		{name: "wildcard matches one level only", domain: "api.app.example.com", patterns: []string{"*.example.com"}, want: false},
355		{name: "empty allowlist", domain: "app.example.com", patterns: nil, want: false},
356		{name: "case insensitive", domain: "App.Example.Com", patterns: []string{"APP.EXAMPLE.COM"}, want: true},
357		{name: "multiple patterns", domain: "api.example.com", patterns: []string{"foo.com", "*.example.com"}, want: true},
358	}
359
360	for _, tt := range tests {
361		t.Run(tt.name, func(t *testing.T) {
362			assert.Equal(t, tt.want, auto.MatchesDomainAllowlist(tt.domain, tt.patterns))
363		})
364	}
365}
366
367func TestExtractRepoName(t *testing.T) {
368	tests := []struct {
369		name           string
370		imageRef       string
371		registryDomain string
372		want           string
373	}{
374		{name: "simple tag", imageRef: "myapp:latest", want: "myapp"},
375		{name: "version tag", imageRef: "myapp:v2.0.0", want: "myapp"},
376		{name: "digest", imageRef: "myapp@sha256:abc123", want: "myapp"},
377		{name: "strip registry", imageRef: "registry.example.com/myapp:latest", registryDomain: "registry.example.com", want: "myapp"},
378		{name: "org image", imageRef: "org/myapp:latest", want: "org/myapp"},
379		{name: "registry org image", imageRef: "registry.example.com/org/myapp:latest", registryDomain: "registry.example.com", want: "org/myapp"},
380		{name: "lowercase", imageRef: "MyApp:Latest", want: "myapp"},
381	}
382
383	for _, tt := range tests {
384		t.Run(tt.name, func(t *testing.T) {
385			assert.Equal(t, tt.want, auto.ExtractRepoName(tt.imageRef, tt.registryDomain))
386		})
387	}
388}
389
390// AutoRouteHandler tests
391
392func TestAutoRouteHandler_CanHandle(t *testing.T) {
393	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
394	configSvc := inmocks.NewMockConfigService(t)
395	containerSvc := inmocks.NewMockContainerService(t)
396	blobStorage := mocks.NewMockBlobStorage(t)
397
398	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
399
400	assert.True(t, handler.CanHandle(domain.EventImagePushed))
401	assert.False(t, handler.CanHandle(domain.EventConfigReload))
402}
403
404func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {
405	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
406	configSvc := inmocks.NewMockConfigService(t)
407	containerSvc := inmocks.NewMockContainerService(t)
408	blobStorage := mocks.NewMockBlobStorage(t)
409
410	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
411
412	configSvc.EXPECT().IsAutoRouteEnabled().Return(false)
413
414	event := domain.Event{
415		ID:   "event-123",
416		Type: domain.EventImagePushed,
417		Data: domain.ImagePushedPayload{
418			Name:      "myapp",
419			Reference: "latest",
420			Manifest:  []byte(`{"schemaVersion": 2}`),
421		},
422	}
423
424	err := handler.Handle(context.Background(), event)
425
426	assert.NoError(t, err)
427}
428
429func TestAutoRouteHandler_Handle_InvalidPayload(t *testing.T) {
430	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
431	configSvc := inmocks.NewMockConfigService(t)
432	containerSvc := inmocks.NewMockContainerService(t)
433	blobStorage := mocks.NewMockBlobStorage(t)
434
435	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
436
437	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
438
439	event := domain.Event{
440		ID:   "event-123",
441		Type: domain.EventImagePushed,
442		Data: "invalid payload type",
443	}
444
445	err := handler.Handle(context.Background(), event)
446
447	// Handler skips invalid payload, doesn't error
448	assert.NoError(t, err)
449}
450
451func TestAutoRouteHandler_Handle_EmptyManifest(t *testing.T) {
452	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
453	configSvc := inmocks.NewMockConfigService(t)
454	containerSvc := inmocks.NewMockContainerService(t)
455	blobStorage := mocks.NewMockBlobStorage(t)
456
457	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
458
459	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
460
461	event := domain.Event{
462		ID:   "event-123",
463		Type: domain.EventImagePushed,
464		Data: domain.ImagePushedPayload{
465			Name:      "myapp",
466			Reference: "latest",
467			Manifest:  []byte{},
468		},
469	}
470
471	err := handler.Handle(context.Background(), event)
472
473	assert.NoError(t, err)
474}
475
476func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
477	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
478	configSvc := inmocks.NewMockConfigService(t)
479	containerSvc := inmocks.NewMockContainerService(t)
480	blobStorage := mocks.NewMockBlobStorage(t)
481
482	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
483
484	// Build manifest with config digest
485	manifest := map[string]any{
486		"schemaVersion": 2,
487		"config": map[string]any{
488			"digest": "sha256:configdigest123",
489		},
490	}
491	manifestData, _ := json.Marshal(manifest)
492
493	// Build image config with gordon labels
494	imageConfig := map[string]any{
495		"config": map[string]any{
496			"Labels": map[string]any{
497				"gordon.domain": "app.example.com",
498			},
499		},
500	}
501	configData, _ := json.Marshal(imageConfig)
502
503	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
504	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
505	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com"}, nil)
506
507	// No existing routes
508	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
509
510	// Expect route to be added
511	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
512		Domain: "app.example.com",
513		Image:  "myapp:latest",
514		HTTPS:  true,
515	}).Return(nil)
516
517	// Expect deploy to be triggered
518	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
519		Domain: "app.example.com",
520		Image:  "myapp:latest",
521		HTTPS:  true,
522	}).Return(&domain.Container{ID: "container-1"}, nil)
523
524	event := domain.Event{
525		ID:   "event-123",
526		Type: domain.EventImagePushed,
527		Data: domain.ImagePushedPayload{
528			Name:      "myapp",
529			Reference: "latest",
530			Manifest:  manifestData,
531		},
532	}
533
534	err := handler.Handle(context.Background(), event)
535
536	assert.NoError(t, err)
537}
538
539func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
540	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
541	configSvc := inmocks.NewMockConfigService(t)
542	containerSvc := inmocks.NewMockContainerService(t)
543	blobStorage := mocks.NewMockBlobStorage(t)
544
545	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
546
547	// Build manifest with config digest
548	manifest := map[string]any{
549		"schemaVersion": 2,
550		"config": map[string]any{
551			"digest": "sha256:configdigest123",
552		},
553	}
554	manifestData, _ := json.Marshal(manifest)
555
556	// Build image config with gordon labels
557	imageConfig := map[string]any{
558		"config": map[string]any{
559			"Labels": map[string]any{
560				"gordon.domain": "app.example.com",
561			},
562		},
563	}
564	configData, _ := json.Marshal(imageConfig)
565
566	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
567	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
568	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
569
570	// Existing route with different image
571	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
572		{Domain: "app.example.com", Image: "myapp:v1", HTTPS: true},
573	})
574
575	// Expect route to be updated
576	configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
577		Domain: "app.example.com",
578		Image:  "myapp:v2",
579		HTTPS:  true,
580	}).Return(nil)
581
582	// Expect deploy to be triggered
583	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
584		Domain: "app.example.com",
585		Image:  "myapp:v2",
586		HTTPS:  true,
587	}).Return(&domain.Container{ID: "container-1"}, nil)
588
589	event := domain.Event{
590		ID:   "event-123",
591		Type: domain.EventImagePushed,
592		Data: domain.ImagePushedPayload{
593			Name:      "myapp",
594			Reference: "v2",
595			Manifest:  manifestData,
596		},
597	}
598
599	err := handler.Handle(context.Background(), event)
600
601	assert.NoError(t, err)
602}
603
604func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) {
605	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
606	configSvc := inmocks.NewMockConfigService(t)
607	containerSvc := inmocks.NewMockContainerService(t)
608	blobStorage := mocks.NewMockBlobStorage(t)
609
610	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
611
612	// Build manifest with config digest
613	manifest := map[string]any{
614		"schemaVersion": 2,
615		"config": map[string]any{
616			"digest": "sha256:configdigest123",
617		},
618	}
619	manifestData, _ := json.Marshal(manifest)
620
621	// Build image config with gordon labels
622	imageConfig := map[string]any{
623		"config": map[string]any{
624			"Labels": map[string]any{
625				"gordon.domain": "app.example.com",
626			},
627		},
628	}
629	configData, _ := json.Marshal(imageConfig)
630
631	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
632	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
633	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
634
635	// Existing route with same image - no update needed
636	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
637		{Domain: "app.example.com", Image: "myapp:latest"},
638	})
639
640	// No AddRoute, UpdateRoute, or Deploy calls expected
641
642	event := domain.Event{
643		ID:   "event-123",
644		Type: domain.EventImagePushed,
645		Data: domain.ImagePushedPayload{
646			Name:      "myapp",
647			Reference: "latest",
648			Manifest:  manifestData,
649		},
650	}
651
652	err := handler.Handle(context.Background(), event)
653
654	assert.NoError(t, err)
655}
656
657func TestAutoRouteHandler_Handle_NoDomainLabel(t *testing.T) {
658	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
659	configSvc := inmocks.NewMockConfigService(t)
660	containerSvc := inmocks.NewMockContainerService(t)
661	blobStorage := mocks.NewMockBlobStorage(t)
662
663	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
664
665	// Build manifest with config digest
666	manifest := map[string]any{
667		"schemaVersion": 2,
668		"config": map[string]any{
669			"digest": "sha256:configdigest123",
670		},
671	}
672	manifestData, _ := json.Marshal(manifest)
673
674	// Build image config with no gordon labels
675	imageConfig := map[string]any{
676		"config": map[string]any{
677			"Labels": map[string]any{
678				"other.label": "value",
679			},
680		},
681	}
682	configData, _ := json.Marshal(imageConfig)
683
684	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
685	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
686
687	// No route operations expected
688
689	event := domain.Event{
690		ID:   "event-123",
691		Type: domain.EventImagePushed,
692		Data: domain.ImagePushedPayload{
693			Name:      "myapp",
694			Reference: "latest",
695			Manifest:  manifestData,
696		},
697	}
698
699	err := handler.Handle(context.Background(), event)
700
701	assert.NoError(t, err)
702}
703
704func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
705	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
706	configSvc := inmocks.NewMockConfigService(t)
707	containerSvc := inmocks.NewMockContainerService(t)
708	blobStorage := mocks.NewMockBlobStorage(t)
709
710	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
711
712	// Build manifest with config digest
713	manifest := map[string]any{
714		"schemaVersion": 2,
715		"config": map[string]any{
716			"digest": "sha256:configdigest123",
717		},
718	}
719	manifestData, _ := json.Marshal(manifest)
720
721	// Build image config with multiple domains
722	imageConfig := map[string]any{
723		"config": map[string]any{
724			"Labels": map[string]any{
725				"gordon.domain":  "app.example.com",
726				"gordon.domains": "api.example.com, www.example.com",
727			},
728		},
729	}
730	configData, _ := json.Marshal(imageConfig)
731
732	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
733	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
734	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com", "api.example.com", "www.example.com"}, nil).Once()
735
736	// No existing routes
737	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)
738
739	// Expect routes to be added for all three domains
740	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
741		Domain: "app.example.com",
742		Image:  "myapp:latest",
743		HTTPS:  true,
744	}).Return(nil)
745	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
746		Domain: "api.example.com",
747		Image:  "myapp:latest",
748		HTTPS:  true,
749	}).Return(nil)
750	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
751		Domain: "www.example.com",
752		Image:  "myapp:latest",
753		HTTPS:  true,
754	}).Return(nil)
755
756	// Expect deploy for all three
757	containerSvc.EXPECT().Deploy(mock.Anything, mock.AnythingOfType("domain.Route")).Return(&domain.Container{ID: "container-1"}, nil).Times(3)
758
759	event := domain.Event{
760		ID:   "event-123",
761		Type: domain.EventImagePushed,
762		Data: domain.ImagePushedPayload{
763			Name:      "myapp",
764			Reference: "latest",
765			Manifest:  manifestData,
766		},
767	}
768
769	err := handler.Handle(context.Background(), event)
770
771	assert.NoError(t, err)
772}
773
774func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) {
775	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
776	configSvc := inmocks.NewMockConfigService(t)
777	blobStorage := mocks.NewMockBlobStorage(t)
778
779	// Create handler without container service
780	handler := NewAutoRouteHandler(ctx, configSvc, nil, blobStorage, "registry.example.com")
781
782	route := domain.Route{
783		Domain: "app.example.com",
784		Image:  "myapp:latest",
785	}
786
787	// Should not panic, just log and skip
788	handler.triggerDeploy(ctx, route)
789}
790
791func TestAutoRouteHandler_NewDomain_Allowed(t *testing.T) {
792	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
793	configSvc := inmocks.NewMockConfigService(t)
794	containerSvc := inmocks.NewMockContainerService(t)
795	blobStorage := mocks.NewMockBlobStorage(t)
796	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
797
798	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
799	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil)
800	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(&domain.Container{ID: "c1"}, nil)
801
802	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.example.com"})
803	assert.True(t, created)
804}
805
806func TestAutoRouteHandler_NewDomain_NotAllowed(t *testing.T) {
807	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
808	configSvc := inmocks.NewMockConfigService(t)
809	containerSvc := inmocks.NewMockContainerService(t)
810	blobStorage := mocks.NewMockBlobStorage(t)
811	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
812
813	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
814
815	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.allowed.com"})
816	assert.False(t, created)
817}
818
819func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
820	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
821	configSvc := inmocks.NewMockConfigService(t)
822	containerSvc := inmocks.NewMockContainerService(t)
823	blobStorage := mocks.NewMockBlobStorage(t)
824	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
825
826	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:v1", HTTPS: true}})
827	configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(nil)
828	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(&domain.Container{ID: "c1"}, nil)
829
830	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:v2", []string{"*.example.com"})
831	assert.False(t, created)
832}
833
834func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryHosts_UpdatesRoute(t *testing.T) {
835	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
836	configSvc := inmocks.NewMockConfigService(t)
837	containerSvc := inmocks.NewMockContainerService(t)
838	blobStorage := mocks.NewMockBlobStorage(t)
839	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
840
841	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
842		Domain: "app.example.com",
843		Image:  "old-registry.example.com/app:v1",
844		HTTPS:  true,
845	}})
846	configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
847		Domain: "app.example.com",
848		Image:  "new-registry.example.com/app:v2",
849		HTTPS:  true,
850	}).Return(nil)
851	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
852		Domain: "app.example.com",
853		Image:  "new-registry.example.com/app:v2",
854		HTTPS:  true,
855	}).Return(&domain.Container{ID: "c1"}, nil)
856
857	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"})
858	assert.False(t, created)
859}
860
861func TestAutoRouteHandler_UsesRefreshedRegistryDomainsAfterReload(t *testing.T) {
862	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
863	tmpDir := t.TempDir()
864	configFile := filepath.Join(tmpDir, "gordon.toml")
865	require.NoError(t, os.WriteFile(configFile, []byte(`[server]
866	gordon_domain = "old-registry.example.com"
867
868	[routes]
869	"app.example.com" = "old-registry.example.com/app:v1"
870	`), 0600))
871
872	v := viper.New()
873	v.SetConfigFile(configFile)
874	require.NoError(t, v.ReadInConfig())
875
876	configSvc := configusecase.NewService(v, mocks.NewMockEventPublisher(t))
877	require.NoError(t, configSvc.Load(ctx))
878
879	containerSvc := inmocks.NewMockContainerService(t)
880	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, mocks.NewMockBlobStorage(t), "old-registry.example.com")
881
882	require.NoError(t, os.WriteFile(configFile, []byte(`[server]
883	gordon_domain = "new-registry.example.com"
884	legacy_registry_domains = ["old-registry.example.com"]
885
886	[routes]
887	"app.example.com" = "old-registry.example.com/app:v1"
888	`), 0600))
889	require.NoError(t, configSvc.Reload(ctx))
890
891	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
892		Domain: "app.example.com",
893		Image:  "new-registry.example.com/app:v2",
894		HTTPS:  true,
895	}).Return(&domain.Container{ID: "c1"}, nil)
896
897	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"})
898	assert.False(t, created)
899
900	route, err := configSvc.GetRoute(ctx, "app.example.com")
901	require.NoError(t, err)
902	assert.Equal(t, "new-registry.example.com/app:v2", route.Image)
903}
904
905func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
906	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
907	configSvc := inmocks.NewMockConfigService(t)
908	containerSvc := inmocks.NewMockContainerService(t)
909	blobStorage := mocks.NewMockBlobStorage(t)
910	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
911
912	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "oldapp:v1"}})
913
914	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "newapp:v2", []string{"*.example.com"})
915	assert.False(t, created)
916}
917
918func TestAutoRouteHandler_ExistingDomain_DifferentRepoAcrossLegacyAndCurrentRegistryHosts_RejectsRoute(t *testing.T) {
919	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
920	configSvc := inmocks.NewMockConfigService(t)
921	containerSvc := inmocks.NewMockContainerService(t)
922	blobStorage := mocks.NewMockBlobStorage(t)
923	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
924
925	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
926		Domain: "app.example.com",
927		Image:  "old-registry.example.com/oldapp:v1",
928		HTTPS:  true,
929	}})
930
931	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/newapp:v2", []string{"*.example.com"})
932	assert.False(t, created)
933}
934
935func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
936	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
937	configSvc := inmocks.NewMockConfigService(t)
938	containerSvc := inmocks.NewMockContainerService(t)
939	blobStorage := mocks.NewMockBlobStorage(t)
940	extractor := &testEnvExtractor{}
941
942	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, t.TempDir())
943
944	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*.example.com"}, nil).Twice()
945	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once()
946	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil)
947	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(&domain.Container{ID: "c1"}, nil)
948	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:latest"}}).Once()
949
950	handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
951	handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
952
953	assert.Equal(t, 1, extractor.calls)
954	assert.Equal(t, "registry.example.com/myapp:latest", extractor.lastImage)
955	assert.Equal(t, ".env", extractor.lastPath)
956}
957
958func TestAutoRouteHandler_ExtractAndMergeEnvFile_ReturnsExistingParseError(t *testing.T) {
959	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
960	configSvc := inmocks.NewMockConfigService(t)
961	containerSvc := inmocks.NewMockContainerService(t)
962	blobStorage := mocks.NewMockBlobStorage(t)
963	extractor := &testEnvExtractor{data: []byte("FOO=bar\n")}
964
965	envDir := t.TempDir()
966	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, envDir)
967
968	envFileName, err := domainToEnvFileName("app.example.com")
969	require.NoError(t, err)
970	envFileDst := filepath.Join(envDir, envFileName)
971	err = os.WriteFile(envFileDst, []byte("BIG="+strings.Repeat("a", (1<<20)+1)), 0600)
972	require.NoError(t, err)
973
974	err = handler.extractAndMergeEnvFile(context.Background(), "myapp:latest", "app.example.com", ".env")
975	require.Error(t, err)
976	assert.Contains(t, err.Error(), envFileDst)
977	assert.Contains(t, err.Error(), "failed to parse existing env file")
978}
979
980type testEnvExtractor struct {
981	calls     int
982	lastImage string
983	lastPath  string
984	data      []byte
985	err       error
986}
987
988func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef, envFilePath string) ([]byte, error) {
989	e.calls++
990	e.lastImage = imageRef
991	e.lastPath = envFilePath
992	if e.err != nil {
993		return nil, e.err
994	}
995	if e.data != nil {
996		return e.data, nil
997	}
998	return []byte("FOO=bar\n"), nil
999}
1000
1001// collectDomains tests
1002
1003func TestValidateAutoRouteEnvFilePath(t *testing.T) {
1004	tests := []struct {
1005		name    string
1006		path    string
1007		want    string
1008		wantErr bool
1009	}{
1010		{name: "relative path", path: ".env", want: ".env"},
1011		{name: "absolute app path", path: "/app/.env", want: "/app/.env"},
1012		{name: "absolute workspace path", path: "/workspace/.env", want: "/workspace/.env"},
1013		{name: "cleans safe path", path: "/app/config/../.env", want: "/app/.env"},
1014		{name: "absolute traversal escapes app root", path: "/app/../../etc/passwd", wantErr: true},
1015		{name: "absolute non app path", path: "/etc/passwd", wantErr: true},
1016		{name: "empty", path: "", wantErr: true},
1017		{name: "root", path: "/", wantErr: true},
1018		{name: "traversal", path: "../.env", wantErr: true},
1019		{name: "proc", path: "/proc/self/environ", wantErr: true},
1020		{name: "sys", path: "/sys/kernel", wantErr: true},
1021		{name: "dev", path: "/dev/null", wantErr: true},
1022		{name: "run", path: "/run/secrets", wantErr: true},
1023		{name: "var run", path: "/var/run/docker.sock", wantErr: true},
1024	}
1025
1026	for _, tt := range tests {
1027		t.Run(tt.name, func(t *testing.T) {
1028			got, err := validateAutoRouteEnvFilePath(tt.path)
1029			if tt.wantErr {
1030				assert.Error(t, err)
1031				return
1032			}
1033			require.NoError(t, err)
1034			assert.Equal(t, tt.want, got)
1035		})
1036	}
1037}
1038
1039func TestAutoRouteHandler_ExtractAndMergeEnvFile_UsesCleanSafeEnvFilePath(t *testing.T) {
1040	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
1041	extractor := &testEnvExtractor{data: []byte("FOO=bar\n")}
1042	handler := NewAutoRouteHandler(ctx, inmocks.NewMockConfigService(t), inmocks.NewMockContainerService(t), mocks.NewMockBlobStorage(t), "registry.example.com").
1043		WithEnvExtractor(extractor, t.TempDir())
1044
1045	err := handler.extractAndMergeEnvFile(ctx, "myapp:latest", "app.example.com", "/app/config/../.env")
1046	require.NoError(t, err)
1047	assert.Equal(t, "/app/.env", extractor.lastPath)
1048}
1049
1050func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) {
1051	tests := []struct {
1052		name        string
1053		envData     string
1054		wantErr     bool
1055		errContains string
1056	}{
1057		{
1058			name:        "pass reference in value",
1059			envData:     "DB_PASSWORD=${pass:myapp/db}\n",
1060			wantErr:     true,
1061			errContains: "secret reference",
1062		},
1063		{
1064			name:        "sops reference in value",
1065			envData:     "API_KEY=${sops:secrets.yaml#/key}\n",
1066			wantErr:     true,
1067			errContains: "secret reference",
1068		},
1069		{
1070			name:        "pass reference embedded in text",
1071			envData:     "CONNECTION=prefix-${pass:secret}-suffix\n",
1072			wantErr:     true,
1073			errContains: "secret reference",
1074		},
1075		{
1076			name:        "multiple secret references",
1077			envData:     "A=${pass:a}\nB=${sops:b}\n",
1078			wantErr:     true,
1079			errContains: "secret reference",
1080		},
1081		{
1082			name:    "normal env values are allowed",
1083			envData: "FOO=bar\nBAZ=qux\n",
1084			wantErr: false,
1085		},
1086		{
1087			name:    "dollar sign without reference is allowed",
1088			envData: "PRICE=$100\n",
1089			wantErr: false,
1090		},
1091		{
1092			name:    "empty braces are allowed",
1093			envData: "EMPTY=${}\n",
1094			wantErr: false,
1095		},
1096	}
1097
1098	for _, tt := range tests {
1099		t.Run(tt.name, func(t *testing.T) {
1100			ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
1101			configSvc := inmocks.NewMockConfigService(t)
1102			containerSvc := inmocks.NewMockContainerService(t)
1103			blobStorage := mocks.NewMockBlobStorage(t)
1104			extractor := &testEnvExtractor{data: []byte(tt.envData)}
1105
1106			envDir := t.TempDir()
1107			handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").
1108				WithEnvExtractor(extractor, envDir)
1109
1110			err := handler.extractAndMergeEnvFile(context.Background(), "myapp:latest", "app.example.com", ".env")
1111
1112			if tt.wantErr {
1113				assert.Error(t, err)
1114				assert.Contains(t, err.Error(), tt.errContains)
1115				return
1116			}
1117
1118			assert.NoError(t, err)
1119		})
1120	}
1121}
1122
1123func TestAutoRouteHandler_ExtractAndMergeEnvFile_DoesNotPersistSecretReferences(t *testing.T) {
1124	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
1125	configSvc := inmocks.NewMockConfigService(t)
1126	containerSvc := inmocks.NewMockContainerService(t)
1127	blobStorage := mocks.NewMockBlobStorage(t)
1128	extractor := &testEnvExtractor{data: []byte("DB_PASSWORD=${pass:myapp/db}\n")}
1129
1130	envDir := t.TempDir()
1131	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, envDir)
1132
1133	envFileName, err := domainToEnvFileName("app.example.com")
1134	require.NoError(t, err)
1135	envFileDst := filepath.Join(envDir, envFileName)
1136
1137	err = handler.extractAndMergeEnvFile(context.Background(), "myapp:latest", "app.example.com", ".env")
1138	require.ErrorIs(t, err, domain.ErrEnvContainsSecretRef)
1139	assert.NoFileExists(t, envFileDst)
1140}
1141
1142func TestAutoRouteHandler_ExtractAndMergeEnvFile_ReturnsReadErrorForExistingFileIssues(t *testing.T) {
1143	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
1144	configSvc := inmocks.NewMockConfigService(t)
1145	containerSvc := inmocks.NewMockContainerService(t)
1146	blobStorage := mocks.NewMockBlobStorage(t)
1147	extractor := &testEnvExtractor{data: []byte("FOO=bar\n")}
1148
1149	envDir := t.TempDir()
1150	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, envDir)
1151
1152	envFileName, err := domainToEnvFileName("app.example.com")
1153	require.NoError(t, err)
1154	envFileDst := filepath.Join(envDir, envFileName)
1155	require.NoError(t, os.Mkdir(envFileDst, 0700))
1156
1157	err = handler.extractAndMergeEnvFile(context.Background(), "myapp:latest", "app.example.com", ".env")
1158	require.Error(t, err)
1159	assert.Contains(t, err.Error(), envFileDst)
1160	assert.Contains(t, err.Error(), "failed to read existing env file")
1161}
1162
1163func TestAutoRouteHandler_CollectDomains(t *testing.T) {
1164	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
1165	configSvc := inmocks.NewMockConfigService(t)
1166	containerSvc := inmocks.NewMockContainerService(t)
1167	blobStorage := mocks.NewMockBlobStorage(t)
1168
1169	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
1170
1171	tests := []struct {
1172		name     string
1173		labels   *domain.ImageLabels
1174		expected []string
1175	}{
1176		{
1177			name: "single domain",
1178			labels: &domain.ImageLabels{
1179				Domain: "app.example.com",
1180			},
1181			expected: []string{"app.example.com"},
1182		},
1183		{
1184			name: "domains list only",
1185			labels: &domain.ImageLabels{
1186				Domains: []string{"api.example.com", "www.example.com"},
1187			},
1188			expected: []string{"api.example.com", "www.example.com"},
1189		},
1190		{
1191			name: "both domain and domains",
1192			labels: &domain.ImageLabels{
1193				Domain:  "app.example.com",
1194				Domains: []string{"api.example.com"},
1195			},
1196			expected: []string{"app.example.com", "api.example.com"},
1197		},
1198		{
1199			name:     "empty labels",
1200			labels:   &domain.ImageLabels{},
1201			expected: nil,
1202		},
1203	}
1204
1205	for _, tt := range tests {
1206		t.Run(tt.name, func(t *testing.T) {
1207			result := handler.collectDomains(tt.labels)
1208			assert.Equal(t, tt.expected, result)
1209		})
1210	}
1211}
1212
1213// buildImageName tests
1214
1215func TestAutoRouteHandler_BuildImageName(t *testing.T) {
1216	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
1217	configSvc := inmocks.NewMockConfigService(t)
1218	containerSvc := inmocks.NewMockContainerService(t)
1219	blobStorage := mocks.NewMockBlobStorage(t)
1220
1221	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
1222
1223	tests := []struct {
1224		name      string
1225		imageName string
1226		reference string
1227		expected  string
1228	}{
1229		{
1230			name:      "with tag",
1231			imageName: "myapp",
1232			reference: "latest",
1233			expected:  "myapp:latest",
1234		},
1235		{
1236			name:      "with version tag",
1237			imageName: "myapp",
1238			reference: "v1.2.3",
1239			expected:  "myapp:v1.2.3",
1240		},
1241		{
1242			name:      "empty reference",
1243			imageName: "myapp",
1244			reference: "",
1245			expected:  "myapp",
1246		},
1247		{
1248			name:      "with digest",
1249			imageName: "myapp",
1250			reference: "sha256:abc123",
1251			expected:  "myapp@sha256:abc123",
1252		},
1253	}
1254
1255	for _, tt := range tests {
1256		t.Run(tt.name, func(t *testing.T) {
1257			result := handler.buildImageName(tt.imageName, tt.reference)
1258			assert.Equal(t, tt.expected, result)
1259		})
1260	}
1261}
1262
1263// buildFullImageRef tests
1264
1265func TestAutoRouteHandler_BuildFullImageRef(t *testing.T) {
1266	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
1267	configSvc := inmocks.NewMockConfigService(t)
1268	containerSvc := inmocks.NewMockContainerService(t)
1269	blobStorage := mocks.NewMockBlobStorage(t)
1270
1271	tests := []struct {
1272		name           string
1273		registryDomain string
1274		imageName      string
1275		expected       string
1276	}{
1277		{
1278			name:           "simple image with registry",
1279			registryDomain: "registry.example.com",
1280			imageName:      "myapp:latest",
1281			expected:       "registry.example.com/myapp:latest",
1282		},
1283		{
1284			name:           "already has registry prefix",
1285			registryDomain: "registry.example.com",
1286			imageName:      "registry.example.com/myapp:latest",
1287			expected:       "registry.example.com/myapp:latest",
1288		},
1289		{
1290			name:           "external registry reference",
1291			registryDomain: "registry.example.com",
1292			imageName:      "docker.io/library/nginx:latest",
1293			expected:       "docker.io/library/nginx:latest",
1294		},
1295		{
1296			name:           "digest reference",
1297			registryDomain: "registry.example.com",
1298			imageName:      "myapp@sha256:abc123def456",
1299			expected:       "registry.example.com/myapp@sha256:abc123def456",
1300		},
1301		{
1302			name:           "empty registry domain",
1303			registryDomain: "",
1304			imageName:      "myapp:latest",
1305			expected:       "myapp:latest",
1306		},
1307		{
1308			name:           "image without tag",
1309			registryDomain: "registry.example.com",
1310			imageName:      "myapp",
1311			expected:       "registry.example.com/myapp",
1312		},
1313	}
1314
1315	for _, tt := range tests {
1316		t.Run(tt.name, func(t *testing.T) {
1317			handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, tt.registryDomain)
1318			result := handler.buildFullImageRef(tt.imageName)
1319			assert.Equal(t, tt.expected, result)
1320		})
1321	}
1322}

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