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