feat(admin): validate image exists in registry before adding route
- commit
- 8d102b655740b5b8f554ee54d382142eeb0853bc
- author
- bnema · · 2026-01-28 20:45 UTC
- parent
- 7a29f8a
- signature
- unsigned
5 files · +236 -21
M internal/adapters/in/http/admin/handler.go +14 -0
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 16 | 16 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 18 | + | "github.com/bnema/gordon/pkg/validation" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | // maxAdminRequestSize is the maximum allowed size for admin API request bodies. | |
| @@ -31,6 +32,7 @@ type Handler struct { | |||
| 31 | 32 | healthSvc in.HealthService | |
| 32 | 33 | secretSvc in.SecretService | |
| 33 | 34 | logSvc in.LogService | |
| 35 | + | registrySvc in.RegistryService | |
| 34 | 36 | eventBus out.EventPublisher | |
| 35 | 37 | log zerowrap.Logger | |
| 36 | 38 | } | |
| @@ -84,6 +86,7 @@ func NewHandler( | |||
| 84 | 86 | healthSvc in.HealthService, | |
| 85 | 87 | secretSvc in.SecretService, | |
| 86 | 88 | logSvc in.LogService, | |
| 89 | + | registrySvc in.RegistryService, | |
| 87 | 90 | eventBus out.EventPublisher, | |
| 88 | 91 | log zerowrap.Logger, | |
| 89 | 92 | ) *Handler { | |
| @@ -94,6 +97,7 @@ func NewHandler( | |||
| 94 | 97 | healthSvc: healthSvc, | |
| 95 | 98 | secretSvc: secretSvc, | |
| 96 | 99 | logSvc: logSvc, | |
| 100 | + | registrySvc: registrySvc, | |
| 97 | 101 | eventBus: eventBus, | |
| 98 | 102 | log: log, | |
| 99 | 103 | } | |
| @@ -274,6 +278,16 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) { | |||
| 274 | 278 | return | |
| 275 | 279 | } | |
| 276 | 280 | ||
| 281 | + | // Validate image exists in registry before adding route | |
| 282 | + | if h.registrySvc != nil { | |
| 283 | + | imageName, imageRef := validation.ParseImageReference(route.Image) | |
| 284 | + | if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil { | |
| 285 | + | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |
| 286 | + | h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image)) | |
| 287 | + | return | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 277 | 291 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | |
| 278 | 292 | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | |
| 279 | 293 | switch { | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/bnema/gordon/internal/boundaries/in" | 15 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 16 | "github.com/bnema/gordon/internal/boundaries/out" | 16 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 18 | + | "github.com/bnema/gordon/pkg/validation" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | // maxAdminRequestSize is the maximum allowed size for admin API request bodies. | 21 | // maxAdminRequestSize is the maximum allowed size for admin API request bodies. | ||
| @@ -31,6 +32,7 @@ type Handler struct { | |||||
| 31 | healthSvc in.HealthService | 32 | healthSvc in.HealthService | ||
| 32 | secretSvc in.SecretService | 33 | secretSvc in.SecretService | ||
| 33 | logSvc in.LogService | 34 | logSvc in.LogService | ||
| 35 | + | registrySvc in.RegistryService | |||
| 34 | eventBus out.EventPublisher | 36 | eventBus out.EventPublisher | ||
| 35 | log zerowrap.Logger | 37 | log zerowrap.Logger | ||
| 36 | } | 38 | } | ||
| @@ -84,6 +86,7 @@ func NewHandler( | |||||
| 84 | healthSvc in.HealthService, | 86 | healthSvc in.HealthService, | ||
| 85 | secretSvc in.SecretService, | 87 | secretSvc in.SecretService, | ||
| 86 | logSvc in.LogService, | 88 | logSvc in.LogService, | ||
| 89 | + | registrySvc in.RegistryService, | |||
| 87 | eventBus out.EventPublisher, | 90 | eventBus out.EventPublisher, | ||
| 88 | log zerowrap.Logger, | 91 | log zerowrap.Logger, | ||
| 89 | ) *Handler { | 92 | ) *Handler { | ||
| @@ -94,6 +97,7 @@ func NewHandler( | |||||
| 94 | healthSvc: healthSvc, | 97 | healthSvc: healthSvc, | ||
| 95 | secretSvc: secretSvc, | 98 | secretSvc: secretSvc, | ||
| 96 | logSvc: logSvc, | 99 | logSvc: logSvc, | ||
| 100 | + | registrySvc: registrySvc, | |||
| 97 | eventBus: eventBus, | 101 | eventBus: eventBus, | ||
| 98 | log: log, | 102 | log: log, | ||
| 99 | } | 103 | } | ||
| @@ -274,6 +278,16 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) { | |||||
| 274 | return | 278 | return | ||
| 275 | } | 279 | } | ||
| 276 | 280 | ||||
| 281 | + | // Validate image exists in registry before adding route | |||
| 282 | + | if h.registrySvc != nil { | |||
| 283 | + | imageName, imageRef := validation.ParseImageReference(route.Image) | |||
| 284 | + | if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil { | |||
| 285 | + | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |||
| 286 | + | h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image)) | |||
| 287 | + | return | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | ||||
| 277 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | 291 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | ||
| 278 | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | 292 | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | ||
| 279 | switch { | 293 | switch { | ||
M internal/adapters/in/http/admin/handler_test.go +81 -20
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | + | "fmt" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "net/http/httptest" | |
| 9 | 10 | "testing" | |
| @@ -33,7 +34,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | |||
| 33 | 34 | containerSvc := inmocks.NewMockContainerService(t) | |
| 34 | 35 | secretSvc := inmocks.NewMockSecretService(t) | |
| 35 | 36 | ||
| 36 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 37 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 37 | 38 | ||
| 38 | 39 | tests := []struct { | |
| 39 | 40 | name string | |
| @@ -94,8 +95,9 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||
| 94 | 95 | authSvc := inmocks.NewMockAuthService(t) | |
| 95 | 96 | containerSvc := inmocks.NewMockContainerService(t) | |
| 96 | 97 | secretSvc := inmocks.NewMockSecretService(t) | |
| 98 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 97 | 99 | ||
| 98 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 100 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) | |
| 99 | 101 | ||
| 100 | 102 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | |
| 101 | 103 | ||
| @@ -135,6 +137,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||
| 135 | 137 | t.Run(tt.name, func(t *testing.T) { | |
| 136 | 138 | if tt.wantStatus == http.StatusCreated { | |
| 137 | 139 | configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | |
| 140 | + | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe() | |
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | 143 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON)) | |
| @@ -153,8 +156,9 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 153 | 156 | authSvc := inmocks.NewMockAuthService(t) | |
| 154 | 157 | containerSvc := inmocks.NewMockContainerService(t) | |
| 155 | 158 | secretSvc := inmocks.NewMockSecretService(t) | |
| 159 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 156 | 160 | ||
| 157 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 161 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) | |
| 158 | 162 | ||
| 159 | 163 | routeJSON := `{"image": "myapp:v2"}` | |
| 160 | 164 | ||
| @@ -198,7 +202,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 198 | 202 | containerSvc := inmocks.NewMockContainerService(t) | |
| 199 | 203 | secretSvc := inmocks.NewMockSecretService(t) | |
| 200 | 204 | ||
| 201 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 205 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 202 | 206 | ||
| 203 | 207 | tests := []struct { | |
| 204 | 208 | name string | |
| @@ -242,7 +246,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||
| 242 | 246 | containerSvc := inmocks.NewMockContainerService(t) | |
| 243 | 247 | secretSvc := inmocks.NewMockSecretService(t) | |
| 244 | 248 | ||
| 245 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 249 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 246 | 250 | ||
| 247 | 251 | tests := []struct { | |
| 248 | 252 | name string | |
| @@ -299,7 +303,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 299 | 303 | containerSvc := inmocks.NewMockContainerService(t) | |
| 300 | 304 | secretSvc := inmocks.NewMockSecretService(t) | |
| 301 | 305 | ||
| 302 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 306 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 303 | 307 | ||
| 304 | 308 | secretsJSON := `{"API_KEY": "secret123"}` | |
| 305 | 309 | ||
| @@ -348,7 +352,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 348 | 352 | containerSvc := inmocks.NewMockContainerService(t) | |
| 349 | 353 | secretSvc := inmocks.NewMockSecretService(t) | |
| 350 | 354 | ||
| 351 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 355 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 352 | 356 | ||
| 353 | 357 | tests := []struct { | |
| 354 | 358 | name string | |
| @@ -392,7 +396,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||
| 392 | 396 | containerSvc := inmocks.NewMockContainerService(t) | |
| 393 | 397 | secretSvc := inmocks.NewMockSecretService(t) | |
| 394 | 398 | ||
| 395 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 399 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 396 | 400 | ||
| 397 | 401 | tests := []struct { | |
| 398 | 402 | name string | |
| @@ -446,7 +450,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||
| 446 | 450 | containerSvc := inmocks.NewMockContainerService(t) | |
| 447 | 451 | secretSvc := inmocks.NewMockSecretService(t) | |
| 448 | 452 | ||
| 449 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 453 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 450 | 454 | ||
| 451 | 455 | tests := []struct { | |
| 452 | 456 | name string | |
| @@ -503,7 +507,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||
| 503 | 507 | containerSvc := inmocks.NewMockContainerService(t) | |
| 504 | 508 | secretSvc := inmocks.NewMockSecretService(t) | |
| 505 | 509 | ||
| 506 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 510 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 507 | 511 | ||
| 508 | 512 | tests := []struct { | |
| 509 | 513 | name string | |
| @@ -557,7 +561,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||
| 557 | 561 | containerSvc := inmocks.NewMockContainerService(t) | |
| 558 | 562 | secretSvc := inmocks.NewMockSecretService(t) | |
| 559 | 563 | ||
| 560 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 564 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 561 | 565 | ||
| 562 | 566 | expectedRoutes := []domain.Route{ | |
| 563 | 567 | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| @@ -592,7 +596,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||
| 592 | 596 | containerSvc := inmocks.NewMockContainerService(t) | |
| 593 | 597 | secretSvc := inmocks.NewMockSecretService(t) | |
| 594 | 598 | ||
| 595 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 599 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 596 | 600 | ||
| 597 | 601 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | |
| 598 | 602 | Domain: "app.example.com", Image: "app:latest", | |
| @@ -623,7 +627,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||
| 623 | 627 | containerSvc := inmocks.NewMockContainerService(t) | |
| 624 | 628 | secretSvc := inmocks.NewMockSecretService(t) | |
| 625 | 629 | ||
| 626 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 630 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 627 | 631 | ||
| 628 | 632 | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | |
| 629 | 633 | ||
| @@ -665,10 +669,12 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||
| 665 | 669 | authSvc := inmocks.NewMockAuthService(t) | |
| 666 | 670 | containerSvc := inmocks.NewMockContainerService(t) | |
| 667 | 671 | secretSvc := inmocks.NewMockSecretService(t) | |
| 672 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 668 | 673 | ||
| 669 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 674 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) | |
| 670 | 675 | ||
| 671 | 676 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | |
| 677 | + | registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe() | |
| 672 | 678 | ||
| 673 | 679 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |
| 674 | 680 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -681,13 +687,68 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||
| 681 | 687 | } | |
| 682 | 688 | } | |
| 683 | 689 | ||
| 690 | + | func TestHandler_RoutesPost_ImageNotFound(t *testing.T) { | |
| 691 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 692 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 693 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 694 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 695 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 696 | + | ||
| 697 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) | |
| 698 | + | ||
| 699 | + | tests := []struct { | |
| 700 | + | name string | |
| 701 | + | image string | |
| 702 | + | body string | |
| 703 | + | manifestName string | |
| 704 | + | manifestRef string | |
| 705 | + | }{ | |
| 706 | + | { | |
| 707 | + | name: "tagged image not found", | |
| 708 | + | image: "myapp:latest", | |
| 709 | + | body: `{"domain": "app.example.com", "image": "myapp:latest"}`, | |
| 710 | + | manifestName: "myapp", | |
| 711 | + | manifestRef: "latest", | |
| 712 | + | }, | |
| 713 | + | { | |
| 714 | + | name: "digest image not found", | |
| 715 | + | image: "myapp@sha256:abc123", | |
| 716 | + | body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`, | |
| 717 | + | manifestName: "myapp", | |
| 718 | + | manifestRef: "sha256:abc123", | |
| 719 | + | }, | |
| 720 | + | { | |
| 721 | + | name: "implicit latest tag not found", | |
| 722 | + | image: "myapp", | |
| 723 | + | body: `{"domain": "app.example.com", "image": "myapp"}`, | |
| 724 | + | manifestName: "myapp", | |
| 725 | + | manifestRef: "latest", | |
| 726 | + | }, | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | for _, tt := range tests { | |
| 730 | + | t.Run(tt.name, func(t *testing.T) { | |
| 731 | + | registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, fmt.Errorf("manifest not found")) | |
| 732 | + | ||
| 733 | + | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |
| 734 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| 735 | + | rec := httptest.NewRecorder() | |
| 736 | + | ||
| 737 | + | handler.ServeHTTP(rec, req) | |
| 738 | + | ||
| 739 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 740 | + | assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image)) | |
| 741 | + | }) | |
| 742 | + | } | |
| 743 | + | } | |
| 744 | + | ||
| 684 | 745 | func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | |
| 685 | 746 | configSvc := inmocks.NewMockConfigService(t) | |
| 686 | 747 | authSvc := inmocks.NewMockAuthService(t) | |
| 687 | 748 | containerSvc := inmocks.NewMockContainerService(t) | |
| 688 | 749 | secretSvc := inmocks.NewMockSecretService(t) | |
| 689 | 750 | ||
| 690 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 751 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 691 | 752 | ||
| 692 | 753 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | |
| 693 | 754 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -704,7 +765,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||
| 704 | 765 | containerSvc := inmocks.NewMockContainerService(t) | |
| 705 | 766 | secretSvc := inmocks.NewMockSecretService(t) | |
| 706 | 767 | ||
| 707 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 768 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 708 | 769 | ||
| 709 | 770 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | |
| 710 | 771 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -721,7 +782,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||
| 721 | 782 | containerSvc := inmocks.NewMockContainerService(t) | |
| 722 | 783 | secretSvc := inmocks.NewMockSecretService(t) | |
| 723 | 784 | ||
| 724 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 785 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 725 | 786 | ||
| 726 | 787 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | |
| 727 | 788 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -738,7 +799,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||
| 738 | 799 | containerSvc := inmocks.NewMockContainerService(t) | |
| 739 | 800 | secretSvc := inmocks.NewMockSecretService(t) | |
| 740 | 801 | ||
| 741 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 802 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 742 | 803 | ||
| 743 | 804 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | |
| 744 | 805 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -755,7 +816,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||
| 755 | 816 | containerSvc := inmocks.NewMockContainerService(t) | |
| 756 | 817 | secretSvc := inmocks.NewMockSecretService(t) | |
| 757 | 818 | ||
| 758 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 819 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 759 | 820 | ||
| 760 | 821 | tests := []struct { | |
| 761 | 822 | method string | |
| @@ -788,7 +849,7 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 788 | 849 | containerSvc := inmocks.NewMockContainerService(t) | |
| 789 | 850 | secretSvc := inmocks.NewMockSecretService(t) | |
| 790 | 851 | ||
| 791 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 852 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 792 | 853 | ||
| 793 | 854 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | |
| 794 | 855 | req = req.WithContext(ctxWithScopes("admin:*:*")) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "fmt" | |||
| 7 | "net/http" | 8 | "net/http" | ||
| 8 | "net/http/httptest" | 9 | "net/http/httptest" | ||
| 9 | "testing" | 10 | "testing" | ||
| @@ -33,7 +34,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | |||||
| 33 | containerSvc := inmocks.NewMockContainerService(t) | 34 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 34 | secretSvc := inmocks.NewMockSecretService(t) | 35 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 35 | 36 | ||||
| 36 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 37 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 37 | 38 | ||||
| 38 | tests := []struct { | 39 | tests := []struct { | ||
| 39 | name string | 40 | name string | ||
| @@ -94,8 +95,9 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||||
| 94 | authSvc := inmocks.NewMockAuthService(t) | 95 | authSvc := inmocks.NewMockAuthService(t) | ||
| 95 | containerSvc := inmocks.NewMockContainerService(t) | 96 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 96 | secretSvc := inmocks.NewMockSecretService(t) | 97 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 98 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 97 | 99 | ||||
| 98 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 100 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) |
| 99 | 101 | ||||
| 100 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | 102 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | ||
| 101 | 103 | ||||
| @@ -135,6 +137,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||||
| 135 | t.Run(tt.name, func(t *testing.T) { | 137 | t.Run(tt.name, func(t *testing.T) { | ||
| 136 | if tt.wantStatus == http.StatusCreated { | 138 | if tt.wantStatus == http.StatusCreated { | ||
| 137 | configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | 139 | configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | ||
| 140 | + | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe() | |||
| 138 | } | 141 | } | ||
| 139 | 142 | ||||
| 140 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON)) | 143 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON)) | ||
| @@ -153,8 +156,9 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||||
| 153 | authSvc := inmocks.NewMockAuthService(t) | 156 | authSvc := inmocks.NewMockAuthService(t) | ||
| 154 | containerSvc := inmocks.NewMockContainerService(t) | 157 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 155 | secretSvc := inmocks.NewMockSecretService(t) | 158 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 159 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 156 | 160 | ||||
| 157 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 161 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) |
| 158 | 162 | ||||
| 159 | routeJSON := `{"image": "myapp:v2"}` | 163 | routeJSON := `{"image": "myapp:v2"}` | ||
| 160 | 164 | ||||
| @@ -198,7 +202,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||||
| 198 | containerSvc := inmocks.NewMockContainerService(t) | 202 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 199 | secretSvc := inmocks.NewMockSecretService(t) | 203 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 200 | 204 | ||||
| 201 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 205 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 202 | 206 | ||||
| 203 | tests := []struct { | 207 | tests := []struct { | ||
| 204 | name string | 208 | name string | ||
| @@ -242,7 +246,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||||
| 242 | containerSvc := inmocks.NewMockContainerService(t) | 246 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 243 | secretSvc := inmocks.NewMockSecretService(t) | 247 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 244 | 248 | ||||
| 245 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 249 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 246 | 250 | ||||
| 247 | tests := []struct { | 251 | tests := []struct { | ||
| 248 | name string | 252 | name string | ||
| @@ -299,7 +303,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||||
| 299 | containerSvc := inmocks.NewMockContainerService(t) | 303 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 300 | secretSvc := inmocks.NewMockSecretService(t) | 304 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 301 | 305 | ||||
| 302 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 306 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 303 | 307 | ||||
| 304 | secretsJSON := `{"API_KEY": "secret123"}` | 308 | secretsJSON := `{"API_KEY": "secret123"}` | ||
| 305 | 309 | ||||
| @@ -348,7 +352,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||||
| 348 | containerSvc := inmocks.NewMockContainerService(t) | 352 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 349 | secretSvc := inmocks.NewMockSecretService(t) | 353 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 350 | 354 | ||||
| 351 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 355 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 352 | 356 | ||||
| 353 | tests := []struct { | 357 | tests := []struct { | ||
| 354 | name string | 358 | name string | ||
| @@ -392,7 +396,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||||
| 392 | containerSvc := inmocks.NewMockContainerService(t) | 396 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 393 | secretSvc := inmocks.NewMockSecretService(t) | 397 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 394 | 398 | ||||
| 395 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 399 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 396 | 400 | ||||
| 397 | tests := []struct { | 401 | tests := []struct { | ||
| 398 | name string | 402 | name string | ||
| @@ -446,7 +450,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||||
| 446 | containerSvc := inmocks.NewMockContainerService(t) | 450 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 447 | secretSvc := inmocks.NewMockSecretService(t) | 451 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 448 | 452 | ||||
| 449 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 453 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 450 | 454 | ||||
| 451 | tests := []struct { | 455 | tests := []struct { | ||
| 452 | name string | 456 | name string | ||
| @@ -503,7 +507,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||||
| 503 | containerSvc := inmocks.NewMockContainerService(t) | 507 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 504 | secretSvc := inmocks.NewMockSecretService(t) | 508 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 505 | 509 | ||||
| 506 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 510 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 507 | 511 | ||||
| 508 | tests := []struct { | 512 | tests := []struct { | ||
| 509 | name string | 513 | name string | ||
| @@ -557,7 +561,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||||
| 557 | containerSvc := inmocks.NewMockContainerService(t) | 561 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 558 | secretSvc := inmocks.NewMockSecretService(t) | 562 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 559 | 563 | ||||
| 560 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 564 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 561 | 565 | ||||
| 562 | expectedRoutes := []domain.Route{ | 566 | expectedRoutes := []domain.Route{ | ||
| 563 | {Domain: "app1.example.com", Image: "app1:latest"}, | 567 | {Domain: "app1.example.com", Image: "app1:latest"}, | ||
| @@ -592,7 +596,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||||
| 592 | containerSvc := inmocks.NewMockContainerService(t) | 596 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 593 | secretSvc := inmocks.NewMockSecretService(t) | 597 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 594 | 598 | ||||
| 595 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 599 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 596 | 600 | ||||
| 597 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | 601 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | ||
| 598 | Domain: "app.example.com", Image: "app:latest", | 602 | Domain: "app.example.com", Image: "app:latest", | ||
| @@ -623,7 +627,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||||
| 623 | containerSvc := inmocks.NewMockContainerService(t) | 627 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 624 | secretSvc := inmocks.NewMockSecretService(t) | 628 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 625 | 629 | ||||
| 626 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 630 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 627 | 631 | ||||
| 628 | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | 632 | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | ||
| 629 | 633 | ||||
| @@ -665,10 +669,12 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||||
| 665 | authSvc := inmocks.NewMockAuthService(t) | 669 | authSvc := inmocks.NewMockAuthService(t) | ||
| 666 | containerSvc := inmocks.NewMockContainerService(t) | 670 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 667 | secretSvc := inmocks.NewMockSecretService(t) | 671 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 672 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 668 | 673 | ||||
| 669 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 674 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) |
| 670 | 675 | ||||
| 671 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | 676 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | ||
| 677 | + | registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe() | |||
| 672 | 678 | ||||
| 673 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | 679 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | ||
| 674 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 680 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -681,13 +687,68 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||||
| 681 | } | 687 | } | ||
| 682 | } | 688 | } | ||
| 683 | 689 | ||||
| 690 | + | func TestHandler_RoutesPost_ImageNotFound(t *testing.T) { | |||
| 691 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 692 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 693 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 694 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 695 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 696 | + | ||||
| 697 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) | |||
| 698 | + | ||||
| 699 | + | tests := []struct { | |||
| 700 | + | name string | |||
| 701 | + | image string | |||
| 702 | + | body string | |||
| 703 | + | manifestName string | |||
| 704 | + | manifestRef string | |||
| 705 | + | }{ | |||
| 706 | + | { | |||
| 707 | + | name: "tagged image not found", | |||
| 708 | + | image: "myapp:latest", | |||
| 709 | + | body: `{"domain": "app.example.com", "image": "myapp:latest"}`, | |||
| 710 | + | manifestName: "myapp", | |||
| 711 | + | manifestRef: "latest", | |||
| 712 | + | }, | |||
| 713 | + | { | |||
| 714 | + | name: "digest image not found", | |||
| 715 | + | image: "myapp@sha256:abc123", | |||
| 716 | + | body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`, | |||
| 717 | + | manifestName: "myapp", | |||
| 718 | + | manifestRef: "sha256:abc123", | |||
| 719 | + | }, | |||
| 720 | + | { | |||
| 721 | + | name: "implicit latest tag not found", | |||
| 722 | + | image: "myapp", | |||
| 723 | + | body: `{"domain": "app.example.com", "image": "myapp"}`, | |||
| 724 | + | manifestName: "myapp", | |||
| 725 | + | manifestRef: "latest", | |||
| 726 | + | }, | |||
| 727 | + | } | |||
| 728 | + | ||||
| 729 | + | for _, tt := range tests { | |||
| 730 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 731 | + | registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, fmt.Errorf("manifest not found")) | |||
| 732 | + | ||||
| 733 | + | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |||
| 734 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |||
| 735 | + | rec := httptest.NewRecorder() | |||
| 736 | + | ||||
| 737 | + | handler.ServeHTTP(rec, req) | |||
| 738 | + | ||||
| 739 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 740 | + | assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image)) | |||
| 741 | + | }) | |||
| 742 | + | } | |||
| 743 | + | } | |||
| 744 | + | ||||
| 684 | func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | 745 | func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | ||
| 685 | configSvc := inmocks.NewMockConfigService(t) | 746 | configSvc := inmocks.NewMockConfigService(t) | ||
| 686 | authSvc := inmocks.NewMockAuthService(t) | 747 | authSvc := inmocks.NewMockAuthService(t) | ||
| 687 | containerSvc := inmocks.NewMockContainerService(t) | 748 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 688 | secretSvc := inmocks.NewMockSecretService(t) | 749 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 689 | 750 | ||||
| 690 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 751 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 691 | 752 | ||||
| 692 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | 753 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | ||
| 693 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 754 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -704,7 +765,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||||
| 704 | containerSvc := inmocks.NewMockContainerService(t) | 765 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 705 | secretSvc := inmocks.NewMockSecretService(t) | 766 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 706 | 767 | ||||
| 707 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 768 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 708 | 769 | ||||
| 709 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | 770 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | ||
| 710 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 771 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -721,7 +782,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||||
| 721 | containerSvc := inmocks.NewMockContainerService(t) | 782 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 722 | secretSvc := inmocks.NewMockSecretService(t) | 783 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 723 | 784 | ||||
| 724 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 785 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 725 | 786 | ||||
| 726 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | 787 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | ||
| 727 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | 788 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | ||
| @@ -738,7 +799,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||||
| 738 | containerSvc := inmocks.NewMockContainerService(t) | 799 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 739 | secretSvc := inmocks.NewMockSecretService(t) | 800 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 740 | 801 | ||||
| 741 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 802 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 742 | 803 | ||||
| 743 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | 804 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | ||
| 744 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | 805 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | ||
| @@ -755,7 +816,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||||
| 755 | containerSvc := inmocks.NewMockContainerService(t) | 816 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 756 | secretSvc := inmocks.NewMockSecretService(t) | 817 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 757 | 818 | ||||
| 758 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 819 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 759 | 820 | ||||
| 760 | tests := []struct { | 821 | tests := []struct { | ||
| 761 | method string | 822 | method string | ||
| @@ -788,7 +849,7 @@ func TestHandler_NotFound(t *testing.T) { | |||||
| 788 | containerSvc := inmocks.NewMockContainerService(t) | 849 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 789 | secretSvc := inmocks.NewMockSecretService(t) | 850 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 790 | 851 | ||||
| 791 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 852 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 792 | 853 | ||||
| 793 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | 854 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | ||
| 794 | req = req.WithContext(ctxWithScopes("admin:*:*")) | 855 | req = req.WithContext(ctxWithScopes("admin:*:*")) | ||
M internal/app/run.go +1 -1
| @@ -333,7 +333,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 333 | 333 | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | |
| 334 | 334 | ||
| 335 | 335 | // Create admin handler for admin API | |
| 336 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.eventBus, log) | |
| 336 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log) | |
| 337 | 337 | ||
| 338 | 338 | return svc, nil | |
| 339 | 339 | } | |
| @@ -333,7 +333,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 333 | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | 333 | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | ||
| 334 | 334 | ||||
| 335 | // Create admin handler for admin API | 335 | // Create admin handler for admin API | ||
| 336 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.eventBus, log) | 336 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log) |
| 337 | 337 | ||||
| 338 | return svc, nil | 338 | return svc, nil | ||
| 339 | } | 339 | } | ||
A pkg/validation/image.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package validation | |
| 2 | + | ||
| 3 | + | // ParseImageReference parses an image reference into name and tag/digest. | |
| 4 | + | // Supports formats: | |
| 5 | + | // - image:tag (default tag is "latest") | |
| 6 | + | // - image@sha256:... (digest) | |
| 7 | + | // - image (defaults to "latest") | |
| 8 | + | // - registry.example.com/image:tag | |
| 9 | + | // - registry.example.com/image@sha256:... | |
| 10 | + | // | |
| 11 | + | // Returns: | |
| 12 | + | // - name: the image name (including registry if specified) | |
| 13 | + | // - reference: the tag (e.g., "latest") or digest (e.g., "sha256:...") | |
| 14 | + | func ParseImageReference(imageRef string) (string, string) { | |
| 15 | + | // Check for digest reference (@sha256:...) | |
| 16 | + | if idx := indexString(imageRef, "@sha256:"); idx != -1 { | |
| 17 | + | return imageRef[:idx], imageRef[idx+1:] // Return name and full digest | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Check for tag reference (:tag) | |
| 21 | + | if idx := indexString(imageRef, ":"); idx != -1 { | |
| 22 | + | // Check if this is actually a port number (e.g., registry:5000/image) | |
| 23 | + | // by looking for a slash before the colon | |
| 24 | + | if slashIdx := indexString(imageRef, "/"); slashIdx != -1 && slashIdx > idx { | |
| 25 | + | // Slash comes after colon, so colon is part of registry port | |
| 26 | + | // Look for another colon for the tag | |
| 27 | + | if tagIdx := indexString(imageRef[slashIdx:], ":"); tagIdx != -1 { | |
| 28 | + | actualTagIdx := slashIdx + tagIdx | |
| 29 | + | return imageRef[:actualTagIdx], imageRef[actualTagIdx+1:] | |
| 30 | + | } | |
| 31 | + | return imageRef, "latest" | |
| 32 | + | } | |
| 33 | + | return imageRef[:idx], imageRef[idx+1:] // Return name and tag | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Default to "latest" tag | |
| 37 | + | return imageRef, "latest" | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // indexString returns the index of the first occurrence of substr in s, or -1 if not found. | |
| 41 | + | // This is a helper function that mirrors strings.Index for testing purposes. | |
| 42 | + | func indexString(s, substr string) int { | |
| 43 | + | for i := 0; i <= len(s)-len(substr); i++ { | |
| 44 | + | if s[i:i+len(substr)] == substr { | |
| 45 | + | return i | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | return -1 | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package validation | |||
| 2 | + | ||||
| 3 | + | // ParseImageReference parses an image reference into name and tag/digest. | |||
| 4 | + | // Supports formats: | |||
| 5 | + | // - image:tag (default tag is "latest") | |||
| 6 | + | // - image@sha256:... (digest) | |||
| 7 | + | // - image (defaults to "latest") | |||
| 8 | + | // - registry.example.com/image:tag | |||
| 9 | + | // - registry.example.com/image@sha256:... | |||
| 10 | + | // | |||
| 11 | + | // Returns: | |||
| 12 | + | // - name: the image name (including registry if specified) | |||
| 13 | + | // - reference: the tag (e.g., "latest") or digest (e.g., "sha256:...") | |||
| 14 | + | func ParseImageReference(imageRef string) (string, string) { | |||
| 15 | + | // Check for digest reference (@sha256:...) | |||
| 16 | + | if idx := indexString(imageRef, "@sha256:"); idx != -1 { | |||
| 17 | + | return imageRef[:idx], imageRef[idx+1:] // Return name and full digest | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Check for tag reference (:tag) | |||
| 21 | + | if idx := indexString(imageRef, ":"); idx != -1 { | |||
| 22 | + | // Check if this is actually a port number (e.g., registry:5000/image) | |||
| 23 | + | // by looking for a slash before the colon | |||
| 24 | + | if slashIdx := indexString(imageRef, "/"); slashIdx != -1 && slashIdx > idx { | |||
| 25 | + | // Slash comes after colon, so colon is part of registry port | |||
| 26 | + | // Look for another colon for the tag | |||
| 27 | + | if tagIdx := indexString(imageRef[slashIdx:], ":"); tagIdx != -1 { | |||
| 28 | + | actualTagIdx := slashIdx + tagIdx | |||
| 29 | + | return imageRef[:actualTagIdx], imageRef[actualTagIdx+1:] | |||
| 30 | + | } | |||
| 31 | + | return imageRef, "latest" | |||
| 32 | + | } | |||
| 33 | + | return imageRef[:idx], imageRef[idx+1:] // Return name and tag | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // Default to "latest" tag | |||
| 37 | + | return imageRef, "latest" | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // indexString returns the index of the first occurrence of substr in s, or -1 if not found. | |||
| 41 | + | // This is a helper function that mirrors strings.Index for testing purposes. | |||
| 42 | + | func indexString(s, substr string) int { | |||
| 43 | + | for i := 0; i <= len(s)-len(substr); i++ { | |||
| 44 | + | if s[i:i+len(substr)] == substr { | |||
| 45 | + | return i | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | return -1 | |||
| 49 | + | } | |||
A pkg/validation/image_test.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | package validation | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestParseImageReference(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | imageRef string | |
| 13 | + | wantName string | |
| 14 | + | wantRef string | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "simple image with latest tag", | |
| 18 | + | imageRef: "myapp:latest", | |
| 19 | + | wantName: "myapp", | |
| 20 | + | wantRef: "latest", | |
| 21 | + | }, | |
| 22 | + | { | |
| 23 | + | name: "simple image with custom tag", | |
| 24 | + | imageRef: "myapp:v1.0.0", | |
| 25 | + | wantName: "myapp", | |
| 26 | + | wantRef: "v1.0.0", | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "simple image with digest", | |
| 30 | + | imageRef: "myapp@sha256:abc123def456", | |
| 31 | + | wantName: "myapp", | |
| 32 | + | wantRef: "sha256:abc123def456", | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "registry with image and tag", | |
| 36 | + | imageRef: "registry.example.com/myapp:latest", | |
| 37 | + | wantName: "registry.example.com/myapp", | |
| 38 | + | wantRef: "latest", | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "registry with port and image and tag", | |
| 42 | + | imageRef: "registry.example.com:5000/myapp:v1.0", | |
| 43 | + | wantName: "registry.example.com:5000/myapp", | |
| 44 | + | wantRef: "v1.0", | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: "registry with port, image, and digest", | |
| 48 | + | imageRef: "registry.example.com:5000/myapp@sha256:abc123", | |
| 49 | + | wantName: "registry.example.com:5000/myapp", | |
| 50 | + | wantRef: "sha256:abc123", | |
| 51 | + | }, | |
| 52 | + | { | |
| 53 | + | name: "simple image without tag (defaults to latest)", | |
| 54 | + | imageRef: "myapp", | |
| 55 | + | wantName: "myapp", | |
| 56 | + | wantRef: "latest", | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | name: "registry with image without tag", | |
| 60 | + | imageRef: "registry.example.com/myapp", | |
| 61 | + | wantName: "registry.example.com/myapp", | |
| 62 | + | wantRef: "latest", | |
| 63 | + | }, | |
| 64 | + | { | |
| 65 | + | name: "nested path image with tag", | |
| 66 | + | imageRef: "registry.example.com/path/to/myapp:latest", | |
| 67 | + | wantName: "registry.example.com/path/to/myapp", | |
| 68 | + | wantRef: "latest", | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | name: "localhost registry with port", | |
| 72 | + | imageRef: "localhost:5000/myapp:latest", | |
| 73 | + | wantName: "localhost:5000/myapp", | |
| 74 | + | wantRef: "latest", | |
| 75 | + | }, | |
| 76 | + | { | |
| 77 | + | name: "IP address registry with port", | |
| 78 | + | imageRef: "192.168.1.100:5000/myapp:latest", | |
| 79 | + | wantName: "192.168.1.100:5000/myapp", | |
| 80 | + | wantRef: "latest", | |
| 81 | + | }, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | for _, tt := range tests { | |
| 85 | + | t.Run(tt.name, func(t *testing.T) { | |
| 86 | + | gotName, gotRef := ParseImageReference(tt.imageRef) | |
| 87 | + | assert.Equal(t, tt.wantName, gotName, "name mismatch") | |
| 88 | + | assert.Equal(t, tt.wantRef, gotRef, "reference mismatch") | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | package validation | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestParseImageReference(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | imageRef string | |||
| 13 | + | wantName string | |||
| 14 | + | wantRef string | |||
| 15 | + | }{ | |||
| 16 | + | { | |||
| 17 | + | name: "simple image with latest tag", | |||
| 18 | + | imageRef: "myapp:latest", | |||
| 19 | + | wantName: "myapp", | |||
| 20 | + | wantRef: "latest", | |||
| 21 | + | }, | |||
| 22 | + | { | |||
| 23 | + | name: "simple image with custom tag", | |||
| 24 | + | imageRef: "myapp:v1.0.0", | |||
| 25 | + | wantName: "myapp", | |||
| 26 | + | wantRef: "v1.0.0", | |||
| 27 | + | }, | |||
| 28 | + | { | |||
| 29 | + | name: "simple image with digest", | |||
| 30 | + | imageRef: "myapp@sha256:abc123def456", | |||
| 31 | + | wantName: "myapp", | |||
| 32 | + | wantRef: "sha256:abc123def456", | |||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | name: "registry with image and tag", | |||
| 36 | + | imageRef: "registry.example.com/myapp:latest", | |||
| 37 | + | wantName: "registry.example.com/myapp", | |||
| 38 | + | wantRef: "latest", | |||
| 39 | + | }, | |||
| 40 | + | { | |||
| 41 | + | name: "registry with port and image and tag", | |||
| 42 | + | imageRef: "registry.example.com:5000/myapp:v1.0", | |||
| 43 | + | wantName: "registry.example.com:5000/myapp", | |||
| 44 | + | wantRef: "v1.0", | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | name: "registry with port, image, and digest", | |||
| 48 | + | imageRef: "registry.example.com:5000/myapp@sha256:abc123", | |||
| 49 | + | wantName: "registry.example.com:5000/myapp", | |||
| 50 | + | wantRef: "sha256:abc123", | |||
| 51 | + | }, | |||
| 52 | + | { | |||
| 53 | + | name: "simple image without tag (defaults to latest)", | |||
| 54 | + | imageRef: "myapp", | |||
| 55 | + | wantName: "myapp", | |||
| 56 | + | wantRef: "latest", | |||
| 57 | + | }, | |||
| 58 | + | { | |||
| 59 | + | name: "registry with image without tag", | |||
| 60 | + | imageRef: "registry.example.com/myapp", | |||
| 61 | + | wantName: "registry.example.com/myapp", | |||
| 62 | + | wantRef: "latest", | |||
| 63 | + | }, | |||
| 64 | + | { | |||
| 65 | + | name: "nested path image with tag", | |||
| 66 | + | imageRef: "registry.example.com/path/to/myapp:latest", | |||
| 67 | + | wantName: "registry.example.com/path/to/myapp", | |||
| 68 | + | wantRef: "latest", | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | name: "localhost registry with port", | |||
| 72 | + | imageRef: "localhost:5000/myapp:latest", | |||
| 73 | + | wantName: "localhost:5000/myapp", | |||
| 74 | + | wantRef: "latest", | |||
| 75 | + | }, | |||
| 76 | + | { | |||
| 77 | + | name: "IP address registry with port", | |||
| 78 | + | imageRef: "192.168.1.100:5000/myapp:latest", | |||
| 79 | + | wantName: "192.168.1.100:5000/myapp", | |||
| 80 | + | wantRef: "latest", | |||
| 81 | + | }, | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | for _, tt := range tests { | |||
| 85 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 86 | + | gotName, gotRef := ParseImageReference(tt.imageRef) | |||
| 87 | + | assert.Equal(t, tt.wantName, gotName, "name mismatch") | |||
| 88 | + | assert.Equal(t, tt.wantRef, gotRef, "reference mismatch") | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||