1package container
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "strings"
8 "testing"
9
10 "github.com/bnema/zerowrap"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
16 "github.com/bnema/gordon/internal/boundaries/out/mocks"
17 "github.com/bnema/gordon/internal/domain"
18 "github.com/bnema/gordon/internal/usecase/auto"
19)
20
21// domainToEnvFileName tests
22
23func TestDomainToEnvFileName(t *testing.T) {
24 tests := []struct {
25 name string
26 domain string
27 expected string
28 }{
29 {
30 name: "simple domain",
31 domain: "app.example.com",
32 expected: "YXBwLmV4YW1wbGUuY29t.env",
33 },
34 {
35 name: "domain with port",
36 domain: "app.example.com:8080",
37 expected: "YXBwLmV4YW1wbGUuY29tOjgwODA.env",
38 },
39 {
40 name: "domain with path",
41 domain: "app.example.com/api",
42 expected: "YXBwLmV4YW1wbGUuY29tL2FwaQ.env",
43 },
44 {
45 name: "complex domain",
46 domain: "sub.app.example.com:443/v1",
47 expected: "c3ViLmFwcC5leGFtcGxlLmNvbTo0NDMvdjE.env",
48 },
49 {
50 name: "single word",
51 domain: "localhost",
52 expected: "bG9jYWxob3N0.env",
53 },
54 }
55
56 for _, tt := range tests {
57 t.Run(tt.name, func(t *testing.T) {
58 result, err := domainToEnvFileName(tt.domain)
59 require.NoError(t, err)
60 assert.Equal(t, tt.expected, result)
61 })
62 }
63}
64
65// parseEnvFile tests
66
67func TestParseEnvFile(t *testing.T) {
68 tests := []struct {
69 name string
70 input string
71 expected map[string]string
72 wantErr bool
73 }{
74 {
75 name: "simple key-value pairs",
76 input: "FOO=bar\nBAZ=qux",
77 expected: map[string]string{
78 "FOO": "bar",
79 "BAZ": "qux",
80 },
81 },
82 {
83 name: "with comments and empty lines",
84 input: "# This is a comment\nFOO=bar\n\n# Another comment\nBAZ=qux\n",
85 expected: map[string]string{
86 "FOO": "bar",
87 "BAZ": "qux",
88 },
89 },
90 {
91 name: "with quoted values - double quotes",
92 input: `FOO="bar baz"`,
93 expected: map[string]string{
94 "FOO": "bar baz",
95 },
96 },
97 {
98 name: "with quoted values - single quotes",
99 input: `FOO='bar baz'`,
100 expected: map[string]string{
101 "FOO": "bar baz",
102 },
103 },
104 {
105 name: "value with equals sign",
106 input: "DATABASE_URL=postgres://user:pass@host/db?option=value",
107 expected: map[string]string{
108 "DATABASE_URL": "postgres://user:pass@host/db?option=value",
109 },
110 },
111 {
112 name: "empty value",
113 input: "EMPTY=",
114 expected: map[string]string{
115 "EMPTY": "",
116 },
117 },
118 {
119 name: "empty file",
120 input: "",
121 expected: map[string]string{},
122 },
123 {
124 name: "only comments",
125 input: "# comment 1\n# comment 2",
126 expected: map[string]string{},
127 },
128 {
129 name: "malformed line without equals",
130 input: "MALFORMED\nGOOD=value",
131 expected: map[string]string{"GOOD": "value"},
132 },
133 {
134 name: "whitespace trimming",
135 input: " FOO = bar \n BAZ=qux",
136 expected: map[string]string{
137 "FOO": "bar",
138 "BAZ": "qux",
139 },
140 },
141 }
142
143 for _, tt := range tests {
144 t.Run(tt.name, func(t *testing.T) {
145 result, err := parseEnvFile([]byte(tt.input))
146
147 if tt.wantErr {
148 assert.Error(t, err)
149 return
150 }
151
152 require.NoError(t, err)
153 assert.Equal(t, tt.expected, result)
154 })
155 }
156}
157
158// parseConfigDigest tests — now delegated to auto package; tested there directly.
159// These tests call auto.ParseConfigDigest to maintain coverage for this package.
160
161func TestParseConfigDigest(t *testing.T) {
162 tests := []struct {
163 name string
164 manifest map[string]any
165 expected string
166 wantErr bool
167 }{
168 {
169 name: "valid manifest v2",
170 manifest: map[string]any{
171 "schemaVersion": 2,
172 "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
173 "config": map[string]any{
174 "mediaType": "application/vnd.docker.container.image.v1+json",
175 "digest": "sha256:abc123",
176 "size": 1234,
177 },
178 },
179 expected: "sha256:abc123",
180 },
181 {
182 name: "OCI manifest",
183 manifest: map[string]any{
184 "schemaVersion": 2,
185 "mediaType": "application/vnd.oci.image.manifest.v1+json",
186 "config": map[string]any{
187 "mediaType": "application/vnd.oci.image.config.v1+json",
188 "digest": "sha256:def456",
189 "size": 5678,
190 },
191 },
192 expected: "sha256:def456",
193 },
194 {
195 name: "empty config digest",
196 manifest: map[string]any{
197 "schemaVersion": 2,
198 "config": map[string]any{
199 "digest": "",
200 },
201 },
202 expected: "",
203 },
204 }
205
206 for _, tt := range tests {
207 t.Run(tt.name, func(t *testing.T) {
208 data, err := json.Marshal(tt.manifest)
209 require.NoError(t, err)
210
211 result, err := auto.ParseConfigDigest(data)
212
213 if tt.wantErr {
214 assert.Error(t, err)
215 return
216 }
217
218 require.NoError(t, err)
219 assert.Equal(t, tt.expected, result)
220 })
221 }
222}
223
224func TestParseConfigDigest_InvalidJSON(t *testing.T) {
225 _, err := auto.ParseConfigDigest([]byte("invalid json"))
226 assert.Error(t, err)
227}
228
229// parseImageLabels tests — now delegated to auto package; tested there directly.
230
231func TestParseImageLabels(t *testing.T) {
232 tests := []struct {
233 name string
234 config map[string]any
235 expected *domain.ImageLabels
236 }{
237 {
238 name: "single domain label",
239 config: map[string]any{
240 "config": map[string]any{
241 "Labels": map[string]any{
242 "gordon.domain": "app.example.com",
243 },
244 },
245 },
246 expected: &domain.ImageLabels{
247 Domain: "app.example.com",
248 },
249 },
250 {
251 name: "multiple domains label",
252 config: map[string]any{
253 "config": map[string]any{
254 "Labels": map[string]any{
255 "gordon.domains": "app1.example.com, app2.example.com",
256 },
257 },
258 },
259 expected: &domain.ImageLabels{
260 Domains: []string{"app1.example.com", "app2.example.com"},
261 },
262 },
263 {
264 name: "all gordon labels",
265 config: map[string]any{
266 "config": map[string]any{
267 "Labels": map[string]any{
268 "gordon.domain": "app.example.com",
269 "gordon.health": "/healthz",
270 "gordon.proxy.port": "8080",
271 "gordon.env-file": ".env.prod",
272 },
273 },
274 },
275 expected: &domain.ImageLabels{
276 Domain: "app.example.com",
277 Health: "/healthz",
278 Port: "8080",
279 EnvFile: ".env.prod",
280 },
281 },
282 {
283 name: "no labels",
284 config: map[string]any{
285 "config": map[string]any{
286 "Labels": nil,
287 },
288 },
289 expected: &domain.ImageLabels{},
290 },
291 {
292 name: "no gordon labels",
293 config: map[string]any{
294 "config": map[string]any{
295 "Labels": map[string]any{
296 "other.label": "value",
297 },
298 },
299 },
300 expected: &domain.ImageLabels{},
301 },
302 {
303 name: "whitespace in labels",
304 config: map[string]any{
305 "config": map[string]any{
306 "Labels": map[string]any{
307 "gordon.domain": " app.example.com ",
308 },
309 },
310 },
311 expected: &domain.ImageLabels{
312 Domain: "app.example.com",
313 },
314 },
315 }
316
317 for _, tt := range tests {
318 t.Run(tt.name, func(t *testing.T) {
319 data, err := json.Marshal(tt.config)
320 require.NoError(t, err)
321
322 result, err := auto.ParseImageLabels(data)
323 require.NoError(t, err)
324
325 assert.Equal(t, tt.expected.Domain, result.Domain)
326 assert.Equal(t, tt.expected.Domains, result.Domains)
327 assert.Equal(t, tt.expected.Health, result.Health)
328 assert.Equal(t, tt.expected.Port, result.Port)
329 assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
330 })
331 }
332}
333
334func TestParseImageLabels_InvalidJSON(t *testing.T) {
335 _, err := auto.ParseImageLabels([]byte("invalid json"))
336 assert.Error(t, err)
337}
338
339func TestMatchesDomainAllowlist(t *testing.T) {
340 tests := []struct {
341 name string
342 domain string
343 patterns []string
344 want bool
345 }{
346 {name: "exact match", domain: "app.example.com", patterns: []string{"app.example.com"}, want: true},
347 {name: "no match", domain: "app.example.com", patterns: []string{"api.example.com"}, want: false},
348 {name: "wildcard match", domain: "app.example.com", patterns: []string{"*.example.com"}, want: true},
349 {name: "wildcard no match on root", domain: "example.com", patterns: []string{"*.example.com"}, want: false},
350 {name: "wildcard matches one level only", domain: "api.app.example.com", patterns: []string{"*.example.com"}, want: false},
351 {name: "empty allowlist", domain: "app.example.com", patterns: nil, want: false},
352 {name: "case insensitive", domain: "App.Example.Com", patterns: []string{"APP.EXAMPLE.COM"}, want: true},
353 {name: "multiple patterns", domain: "api.example.com", patterns: []string{"foo.com", "*.example.com"}, want: true},
354 }
355
356 for _, tt := range tests {
357 t.Run(tt.name, func(t *testing.T) {
358 assert.Equal(t, tt.want, auto.MatchesDomainAllowlist(tt.domain, tt.patterns))
359 })
360 }
361}
362
363func TestExtractRepoName(t *testing.T) {
364 tests := []struct {
365 name string
366 imageRef string
367 registryDomain string
368 want string
369 }{
370 {name: "simple tag", imageRef: "myapp:latest", want: "myapp"},
371 {name: "version tag", imageRef: "myapp:v2.0.0", want: "myapp"},
372 {name: "digest", imageRef: "myapp@sha256:abc123", want: "myapp"},
373 {name: "strip registry", imageRef: "registry.example.com/myapp:latest", registryDomain: "registry.example.com", want: "myapp"},
374 {name: "org image", imageRef: "org/myapp:latest", want: "org/myapp"},
375 {name: "registry org image", imageRef: "registry.example.com/org/myapp:latest", registryDomain: "registry.example.com", want: "org/myapp"},
376 {name: "lowercase", imageRef: "MyApp:Latest", want: "myapp"},
377 }
378
379 for _, tt := range tests {
380 t.Run(tt.name, func(t *testing.T) {
381 assert.Equal(t, tt.want, auto.ExtractRepoName(tt.imageRef, tt.registryDomain))
382 })
383 }
384}
385
386// AutoRouteHandler tests
387
388func TestAutoRouteHandler_CanHandle(t *testing.T) {
389 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
390 configSvc := inmocks.NewMockConfigService(t)
391 containerSvc := inmocks.NewMockContainerService(t)
392 blobStorage := mocks.NewMockBlobStorage(t)
393
394 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
395
396 assert.True(t, handler.CanHandle(domain.EventImagePushed))
397 assert.False(t, handler.CanHandle(domain.EventConfigReload))
398 assert.False(t, handler.CanHandle(domain.EventManualReload))
399}
400
401func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {
402 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
403 configSvc := inmocks.NewMockConfigService(t)
404 containerSvc := inmocks.NewMockContainerService(t)
405 blobStorage := mocks.NewMockBlobStorage(t)
406
407 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
408
409 configSvc.EXPECT().IsAutoRouteEnabled().Return(false)
410
411 event := domain.Event{
412 ID: "event-123",
413 Type: domain.EventImagePushed,
414 Data: domain.ImagePushedPayload{
415 Name: "myapp",
416 Reference: "latest",
417 Manifest: []byte(`{"schemaVersion": 2}`),
418 },
419 }
420
421 err := handler.Handle(context.Background(), event)
422
423 assert.NoError(t, err)
424}
425
426func TestAutoRouteHandler_Handle_InvalidPayload(t *testing.T) {
427 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
428 configSvc := inmocks.NewMockConfigService(t)
429 containerSvc := inmocks.NewMockContainerService(t)
430 blobStorage := mocks.NewMockBlobStorage(t)
431
432 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
433
434 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
435
436 event := domain.Event{
437 ID: "event-123",
438 Type: domain.EventImagePushed,
439 Data: "invalid payload type",
440 }
441
442 err := handler.Handle(context.Background(), event)
443
444 // Handler skips invalid payload, doesn't error
445 assert.NoError(t, err)
446}
447
448func TestAutoRouteHandler_Handle_EmptyManifest(t *testing.T) {
449 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
450 configSvc := inmocks.NewMockConfigService(t)
451 containerSvc := inmocks.NewMockContainerService(t)
452 blobStorage := mocks.NewMockBlobStorage(t)
453
454 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
455
456 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
457
458 event := domain.Event{
459 ID: "event-123",
460 Type: domain.EventImagePushed,
461 Data: domain.ImagePushedPayload{
462 Name: "myapp",
463 Reference: "latest",
464 Manifest: []byte{},
465 },
466 }
467
468 err := handler.Handle(context.Background(), event)
469
470 assert.NoError(t, err)
471}
472
473func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
474 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
475 configSvc := inmocks.NewMockConfigService(t)
476 containerSvc := inmocks.NewMockContainerService(t)
477 blobStorage := mocks.NewMockBlobStorage(t)
478
479 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
480
481 // Build manifest with config digest
482 manifest := map[string]any{
483 "schemaVersion": 2,
484 "config": map[string]any{
485 "digest": "sha256:configdigest123",
486 },
487 }
488 manifestData, _ := json.Marshal(manifest)
489
490 // Build image config with gordon labels
491 imageConfig := map[string]any{
492 "config": map[string]any{
493 "Labels": map[string]any{
494 "gordon.domain": "app.example.com",
495 },
496 },
497 }
498 configData, _ := json.Marshal(imageConfig)
499
500 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
501 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
502 configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com"}, nil)
503
504 // No existing routes
505 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
506
507 // Expect route to be added
508 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
509 Domain: "app.example.com",
510 Image: "myapp:latest",
511 }).Return(nil)
512
513 // Expect deploy to be triggered
514 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
515 Domain: "app.example.com",
516 Image: "myapp:latest",
517 }).Return(&domain.Container{ID: "container-1"}, nil)
518
519 event := domain.Event{
520 ID: "event-123",
521 Type: domain.EventImagePushed,
522 Data: domain.ImagePushedPayload{
523 Name: "myapp",
524 Reference: "latest",
525 Manifest: manifestData,
526 },
527 }
528
529 err := handler.Handle(context.Background(), event)
530
531 assert.NoError(t, err)
532}
533
534func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
535 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
536 configSvc := inmocks.NewMockConfigService(t)
537 containerSvc := inmocks.NewMockContainerService(t)
538 blobStorage := mocks.NewMockBlobStorage(t)
539
540 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
541
542 // Build manifest with config digest
543 manifest := map[string]any{
544 "schemaVersion": 2,
545 "config": map[string]any{
546 "digest": "sha256:configdigest123",
547 },
548 }
549 manifestData, _ := json.Marshal(manifest)
550
551 // Build image config with gordon labels
552 imageConfig := map[string]any{
553 "config": map[string]any{
554 "Labels": map[string]any{
555 "gordon.domain": "app.example.com",
556 },
557 },
558 }
559 configData, _ := json.Marshal(imageConfig)
560
561 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
562 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
563 configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
564
565 // Existing route with different image
566 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
567 {Domain: "app.example.com", Image: "myapp:v1"},
568 })
569
570 // Expect route to be updated
571 configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
572 Domain: "app.example.com",
573 Image: "myapp:v2",
574 }).Return(nil)
575
576 // Expect deploy to be triggered
577 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
578 Domain: "app.example.com",
579 Image: "myapp:v2",
580 }).Return(&domain.Container{ID: "container-1"}, nil)
581
582 event := domain.Event{
583 ID: "event-123",
584 Type: domain.EventImagePushed,
585 Data: domain.ImagePushedPayload{
586 Name: "myapp",
587 Reference: "v2",
588 Manifest: manifestData,
589 },
590 }
591
592 err := handler.Handle(context.Background(), event)
593
594 assert.NoError(t, err)
595}
596
597func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) {
598 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
599 configSvc := inmocks.NewMockConfigService(t)
600 containerSvc := inmocks.NewMockContainerService(t)
601 blobStorage := mocks.NewMockBlobStorage(t)
602
603 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
604
605 // Build manifest with config digest
606 manifest := map[string]any{
607 "schemaVersion": 2,
608 "config": map[string]any{
609 "digest": "sha256:configdigest123",
610 },
611 }
612 manifestData, _ := json.Marshal(manifest)
613
614 // Build image config with gordon labels
615 imageConfig := map[string]any{
616 "config": map[string]any{
617 "Labels": map[string]any{
618 "gordon.domain": "app.example.com",
619 },
620 },
621 }
622 configData, _ := json.Marshal(imageConfig)
623
624 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
625 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
626 configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
627
628 // Existing route with same image - no update needed
629 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
630 {Domain: "app.example.com", Image: "myapp:latest"},
631 })
632
633 // No AddRoute, UpdateRoute, or Deploy calls expected
634
635 event := domain.Event{
636 ID: "event-123",
637 Type: domain.EventImagePushed,
638 Data: domain.ImagePushedPayload{
639 Name: "myapp",
640 Reference: "latest",
641 Manifest: manifestData,
642 },
643 }
644
645 err := handler.Handle(context.Background(), event)
646
647 assert.NoError(t, err)
648}
649
650func TestAutoRouteHandler_Handle_NoDomainLabel(t *testing.T) {
651 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
652 configSvc := inmocks.NewMockConfigService(t)
653 containerSvc := inmocks.NewMockContainerService(t)
654 blobStorage := mocks.NewMockBlobStorage(t)
655
656 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
657
658 // Build manifest with config digest
659 manifest := map[string]any{
660 "schemaVersion": 2,
661 "config": map[string]any{
662 "digest": "sha256:configdigest123",
663 },
664 }
665 manifestData, _ := json.Marshal(manifest)
666
667 // Build image config with no gordon labels
668 imageConfig := map[string]any{
669 "config": map[string]any{
670 "Labels": map[string]any{
671 "other.label": "value",
672 },
673 },
674 }
675 configData, _ := json.Marshal(imageConfig)
676
677 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
678 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
679
680 // No route operations expected
681
682 event := domain.Event{
683 ID: "event-123",
684 Type: domain.EventImagePushed,
685 Data: domain.ImagePushedPayload{
686 Name: "myapp",
687 Reference: "latest",
688 Manifest: manifestData,
689 },
690 }
691
692 err := handler.Handle(context.Background(), event)
693
694 assert.NoError(t, err)
695}
696
697func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
698 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
699 configSvc := inmocks.NewMockConfigService(t)
700 containerSvc := inmocks.NewMockContainerService(t)
701 blobStorage := mocks.NewMockBlobStorage(t)
702
703 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
704
705 // Build manifest with config digest
706 manifest := map[string]any{
707 "schemaVersion": 2,
708 "config": map[string]any{
709 "digest": "sha256:configdigest123",
710 },
711 }
712 manifestData, _ := json.Marshal(manifest)
713
714 // Build image config with multiple domains
715 imageConfig := map[string]any{
716 "config": map[string]any{
717 "Labels": map[string]any{
718 "gordon.domain": "app.example.com",
719 "gordon.domains": "api.example.com, www.example.com",
720 },
721 },
722 }
723 configData, _ := json.Marshal(imageConfig)
724
725 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
726 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
727 configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com", "api.example.com", "www.example.com"}, nil).Once()
728
729 // No existing routes
730 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)
731
732 // Expect routes to be added for all three domains
733 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
734 Domain: "app.example.com",
735 Image: "myapp:latest",
736 }).Return(nil)
737 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
738 Domain: "api.example.com",
739 Image: "myapp:latest",
740 }).Return(nil)
741 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
742 Domain: "www.example.com",
743 Image: "myapp:latest",
744 }).Return(nil)
745
746 // Expect deploy for all three
747 containerSvc.EXPECT().Deploy(mock.Anything, mock.AnythingOfType("domain.Route")).Return(&domain.Container{ID: "container-1"}, nil).Times(3)
748
749 event := domain.Event{
750 ID: "event-123",
751 Type: domain.EventImagePushed,
752 Data: domain.ImagePushedPayload{
753 Name: "myapp",
754 Reference: "latest",
755 Manifest: manifestData,
756 },
757 }
758
759 err := handler.Handle(context.Background(), event)
760
761 assert.NoError(t, err)
762}
763
764func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) {
765 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
766 configSvc := inmocks.NewMockConfigService(t)
767 blobStorage := mocks.NewMockBlobStorage(t)
768
769 // Create handler without container service
770 handler := NewAutoRouteHandler(ctx, configSvc, nil, blobStorage, "registry.example.com")
771
772 route := domain.Route{
773 Domain: "app.example.com",
774 Image: "myapp:latest",
775 }
776
777 // Should not panic, just log and skip
778 handler.triggerDeploy(ctx, route)
779}
780
781func TestAutoRouteHandler_NewDomain_Allowed(t *testing.T) {
782 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
783 configSvc := inmocks.NewMockConfigService(t)
784 containerSvc := inmocks.NewMockContainerService(t)
785 blobStorage := mocks.NewMockBlobStorage(t)
786 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
787
788 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
789 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
790 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
791
792 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.example.com"})
793 assert.True(t, created)
794}
795
796func TestAutoRouteHandler_NewDomain_NotAllowed(t *testing.T) {
797 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
798 configSvc := inmocks.NewMockConfigService(t)
799 containerSvc := inmocks.NewMockContainerService(t)
800 blobStorage := mocks.NewMockBlobStorage(t)
801 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
802
803 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
804
805 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.allowed.com"})
806 assert.False(t, created)
807}
808
809func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
810 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
811 configSvc := inmocks.NewMockConfigService(t)
812 containerSvc := inmocks.NewMockContainerService(t)
813 blobStorage := mocks.NewMockBlobStorage(t)
814 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
815
816 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:v1"}})
817 configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(nil)
818 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(&domain.Container{ID: "c1"}, nil)
819
820 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:v2", []string{"*.example.com"})
821 assert.False(t, created)
822}
823
824func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
825 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
826 configSvc := inmocks.NewMockConfigService(t)
827 containerSvc := inmocks.NewMockContainerService(t)
828 blobStorage := mocks.NewMockBlobStorage(t)
829 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
830
831 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "oldapp:v1"}})
832
833 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "newapp:v2", []string{"*.example.com"})
834 assert.False(t, created)
835}
836
837func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
838 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
839 configSvc := inmocks.NewMockConfigService(t)
840 containerSvc := inmocks.NewMockContainerService(t)
841 blobStorage := mocks.NewMockBlobStorage(t)
842 extractor := &testEnvExtractor{}
843
844 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, t.TempDir())
845
846 configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*.example.com"}, nil).Twice()
847 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once()
848 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
849 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
850 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:latest"}}).Once()
851
852 handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
853 handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
854
855 assert.Equal(t, 1, extractor.calls)
856 assert.Equal(t, "registry.example.com/myapp:latest", extractor.lastImage)
857 assert.Equal(t, ".env", extractor.lastPath)
858}
859
860type testEnvExtractor struct {
861 calls int
862 lastImage string
863 lastPath string
864}
865
866func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef, envFilePath string) ([]byte, error) {
867 e.calls++
868 e.lastImage = imageRef
869 e.lastPath = envFilePath
870 return []byte("FOO=bar\n"), nil
871}
872
873// collectDomains tests
874
875func TestAutoRouteHandler_CollectDomains(t *testing.T) {
876 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
877 configSvc := inmocks.NewMockConfigService(t)
878 containerSvc := inmocks.NewMockContainerService(t)
879 blobStorage := mocks.NewMockBlobStorage(t)
880
881 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
882
883 tests := []struct {
884 name string
885 labels *domain.ImageLabels
886 expected []string
887 }{
888 {
889 name: "single domain",
890 labels: &domain.ImageLabels{
891 Domain: "app.example.com",
892 },
893 expected: []string{"app.example.com"},
894 },
895 {
896 name: "domains list only",
897 labels: &domain.ImageLabels{
898 Domains: []string{"api.example.com", "www.example.com"},
899 },
900 expected: []string{"api.example.com", "www.example.com"},
901 },
902 {
903 name: "both domain and domains",
904 labels: &domain.ImageLabels{
905 Domain: "app.example.com",
906 Domains: []string{"api.example.com"},
907 },
908 expected: []string{"app.example.com", "api.example.com"},
909 },
910 {
911 name: "empty labels",
912 labels: &domain.ImageLabels{},
913 expected: nil,
914 },
915 }
916
917 for _, tt := range tests {
918 t.Run(tt.name, func(t *testing.T) {
919 result := handler.collectDomains(tt.labels)
920 assert.Equal(t, tt.expected, result)
921 })
922 }
923}
924
925// buildImageName tests
926
927func TestAutoRouteHandler_BuildImageName(t *testing.T) {
928 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
929 configSvc := inmocks.NewMockConfigService(t)
930 containerSvc := inmocks.NewMockContainerService(t)
931 blobStorage := mocks.NewMockBlobStorage(t)
932
933 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
934
935 tests := []struct {
936 name string
937 imageName string
938 reference string
939 expected string
940 }{
941 {
942 name: "with tag",
943 imageName: "myapp",
944 reference: "latest",
945 expected: "myapp:latest",
946 },
947 {
948 name: "with version tag",
949 imageName: "myapp",
950 reference: "v1.2.3",
951 expected: "myapp:v1.2.3",
952 },
953 {
954 name: "empty reference",
955 imageName: "myapp",
956 reference: "",
957 expected: "myapp",
958 },
959 {
960 name: "with digest",
961 imageName: "myapp",
962 reference: "sha256:abc123",
963 expected: "myapp@sha256:abc123",
964 },
965 }
966
967 for _, tt := range tests {
968 t.Run(tt.name, func(t *testing.T) {
969 result := handler.buildImageName(tt.imageName, tt.reference)
970 assert.Equal(t, tt.expected, result)
971 })
972 }
973}
974
975// buildFullImageRef tests
976
977func TestAutoRouteHandler_BuildFullImageRef(t *testing.T) {
978 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
979 configSvc := inmocks.NewMockConfigService(t)
980 containerSvc := inmocks.NewMockContainerService(t)
981 blobStorage := mocks.NewMockBlobStorage(t)
982
983 tests := []struct {
984 name string
985 registryDomain string
986 imageName string
987 expected string
988 }{
989 {
990 name: "simple image with registry",
991 registryDomain: "registry.example.com",
992 imageName: "myapp:latest",
993 expected: "registry.example.com/myapp:latest",
994 },
995 {
996 name: "already has registry prefix",
997 registryDomain: "registry.example.com",
998 imageName: "registry.example.com/myapp:latest",
999 expected: "registry.example.com/myapp:latest",
1000 },
1001 {
1002 name: "external registry reference",
1003 registryDomain: "registry.example.com",
1004 imageName: "docker.io/library/nginx:latest",
1005 expected: "docker.io/library/nginx:latest",
1006 },
1007 {
1008 name: "digest reference",
1009 registryDomain: "registry.example.com",
1010 imageName: "myapp@sha256:abc123def456",
1011 expected: "registry.example.com/myapp@sha256:abc123def456",
1012 },
1013 {
1014 name: "empty registry domain",
1015 registryDomain: "",
1016 imageName: "myapp:latest",
1017 expected: "myapp:latest",
1018 },
1019 {
1020 name: "image without tag",
1021 registryDomain: "registry.example.com",
1022 imageName: "myapp",
1023 expected: "registry.example.com/myapp",
1024 },
1025 }
1026
1027 for _, tt := range tests {
1028 t.Run(tt.name, func(t *testing.T) {
1029 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, tt.registryDomain)
1030 result := handler.buildFullImageRef(tt.imageName)
1031 assert.Equal(t, tt.expected, result)
1032 })
1033 }
1034}