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}