Merge pull request #39 from bnema/fix/validate-image-exists-on-route-add
feat(admin): validate image exists in registry before adding route
5 files · +248 -21
M internal/adapters/in/http/admin/handler.go +34 -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,21 @@ 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 | + | if errors.Is(err, domain.ErrManifestNotFound) { | |
| 286 | + | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |
| 287 | + | 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)) | |
| 288 | + | } else { | |
| 289 | + | log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry") | |
| 290 | + | h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry") | |
| 291 | + | } | |
| 292 | + | return | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 277 | 296 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | |
| 278 | 297 | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | |
| 279 | 298 | switch { | |
| @@ -316,6 +335,21 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD | |||
| 316 | 335 | ||
| 317 | 336 | route.Domain = routeDomain | |
| 318 | 337 | ||
| 338 | + | // Validate image exists in registry before updating route | |
| 339 | + | if h.registrySvc != nil && route.Image != "" { | |
| 340 | + | imageName, imageRef := validation.ParseImageReference(route.Image) | |
| 341 | + | if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil { | |
| 342 | + | if errors.Is(err, domain.ErrManifestNotFound) { | |
| 343 | + | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |
| 344 | + | 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)) | |
| 345 | + | } else { | |
| 346 | + | log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry") | |
| 347 | + | h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry") | |
| 348 | + | } | |
| 349 | + | return | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 319 | 353 | if err := h.configSvc.UpdateRoute(ctx, route); err != nil { | |
| 320 | 354 | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route") | |
| 321 | 355 | 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,21 @@ 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 | + | if errors.Is(err, domain.ErrManifestNotFound) { | |||
| 286 | + | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |||
| 287 | + | 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)) | |||
| 288 | + | } else { | |||
| 289 | + | log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry") | |||
| 290 | + | h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry") | |||
| 291 | + | } | |||
| 292 | + | return | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 277 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | 296 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | ||
| 278 | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | 297 | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | ||
| 279 | switch { | 298 | switch { | ||
| @@ -316,6 +335,21 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD | |||||
| 316 | 335 | ||||
| 317 | route.Domain = routeDomain | 336 | route.Domain = routeDomain | ||
| 318 | 337 | ||||
| 338 | + | // Validate image exists in registry before updating route | |||
| 339 | + | if h.registrySvc != nil && route.Image != "" { | |||
| 340 | + | imageName, imageRef := validation.ParseImageReference(route.Image) | |||
| 341 | + | if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil { | |||
| 342 | + | if errors.Is(err, domain.ErrManifestNotFound) { | |||
| 343 | + | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |||
| 344 | + | 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)) | |||
| 345 | + | } else { | |||
| 346 | + | log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry") | |||
| 347 | + | h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry") | |||
| 348 | + | } | |||
| 349 | + | return | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | ||||
| 319 | if err := h.configSvc.UpdateRoute(ctx, route); err != nil { | 353 | if err := h.configSvc.UpdateRoute(ctx, route); err != nil { | ||
| 320 | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route") | 354 | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route") | ||
| 321 | switch { | 355 | switch { | ||
M internal/adapters/in/http/admin/handler_test.go +82 -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 | ||
| @@ -179,6 +183,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 179 | 183 | t.Run(tt.name, func(t *testing.T) { | |
| 180 | 184 | if tt.wantStatus == http.StatusOK { | |
| 181 | 185 | configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | |
| 186 | + | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe() | |
| 182 | 187 | } | |
| 183 | 188 | ||
| 184 | 189 | req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON)) | |
| @@ -198,7 +203,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 198 | 203 | containerSvc := inmocks.NewMockContainerService(t) | |
| 199 | 204 | secretSvc := inmocks.NewMockSecretService(t) | |
| 200 | 205 | ||
| 201 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 206 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 202 | 207 | ||
| 203 | 208 | tests := []struct { | |
| 204 | 209 | name string | |
| @@ -242,7 +247,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||
| 242 | 247 | containerSvc := inmocks.NewMockContainerService(t) | |
| 243 | 248 | secretSvc := inmocks.NewMockSecretService(t) | |
| 244 | 249 | ||
| 245 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 250 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 246 | 251 | ||
| 247 | 252 | tests := []struct { | |
| 248 | 253 | name string | |
| @@ -299,7 +304,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 299 | 304 | containerSvc := inmocks.NewMockContainerService(t) | |
| 300 | 305 | secretSvc := inmocks.NewMockSecretService(t) | |
| 301 | 306 | ||
| 302 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 307 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 303 | 308 | ||
| 304 | 309 | secretsJSON := `{"API_KEY": "secret123"}` | |
| 305 | 310 | ||
| @@ -348,7 +353,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 348 | 353 | containerSvc := inmocks.NewMockContainerService(t) | |
| 349 | 354 | secretSvc := inmocks.NewMockSecretService(t) | |
| 350 | 355 | ||
| 351 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 356 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 352 | 357 | ||
| 353 | 358 | tests := []struct { | |
| 354 | 359 | name string | |
| @@ -392,7 +397,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||
| 392 | 397 | containerSvc := inmocks.NewMockContainerService(t) | |
| 393 | 398 | secretSvc := inmocks.NewMockSecretService(t) | |
| 394 | 399 | ||
| 395 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 400 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 396 | 401 | ||
| 397 | 402 | tests := []struct { | |
| 398 | 403 | name string | |
| @@ -446,7 +451,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||
| 446 | 451 | containerSvc := inmocks.NewMockContainerService(t) | |
| 447 | 452 | secretSvc := inmocks.NewMockSecretService(t) | |
| 448 | 453 | ||
| 449 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 454 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 450 | 455 | ||
| 451 | 456 | tests := []struct { | |
| 452 | 457 | name string | |
| @@ -503,7 +508,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||
| 503 | 508 | containerSvc := inmocks.NewMockContainerService(t) | |
| 504 | 509 | secretSvc := inmocks.NewMockSecretService(t) | |
| 505 | 510 | ||
| 506 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 511 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 507 | 512 | ||
| 508 | 513 | tests := []struct { | |
| 509 | 514 | name string | |
| @@ -557,7 +562,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||
| 557 | 562 | containerSvc := inmocks.NewMockContainerService(t) | |
| 558 | 563 | secretSvc := inmocks.NewMockSecretService(t) | |
| 559 | 564 | ||
| 560 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 565 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 561 | 566 | ||
| 562 | 567 | expectedRoutes := []domain.Route{ | |
| 563 | 568 | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| @@ -592,7 +597,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||
| 592 | 597 | containerSvc := inmocks.NewMockContainerService(t) | |
| 593 | 598 | secretSvc := inmocks.NewMockSecretService(t) | |
| 594 | 599 | ||
| 595 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 600 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 596 | 601 | ||
| 597 | 602 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | |
| 598 | 603 | Domain: "app.example.com", Image: "app:latest", | |
| @@ -623,7 +628,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||
| 623 | 628 | containerSvc := inmocks.NewMockContainerService(t) | |
| 624 | 629 | secretSvc := inmocks.NewMockSecretService(t) | |
| 625 | 630 | ||
| 626 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 631 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 627 | 632 | ||
| 628 | 633 | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | |
| 629 | 634 | ||
| @@ -665,10 +670,12 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||
| 665 | 670 | authSvc := inmocks.NewMockAuthService(t) | |
| 666 | 671 | containerSvc := inmocks.NewMockContainerService(t) | |
| 667 | 672 | secretSvc := inmocks.NewMockSecretService(t) | |
| 673 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 668 | 674 | ||
| 669 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 675 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) | |
| 670 | 676 | ||
| 671 | 677 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | |
| 678 | + | registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe() | |
| 672 | 679 | ||
| 673 | 680 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |
| 674 | 681 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -681,13 +688,68 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||
| 681 | 688 | } | |
| 682 | 689 | } | |
| 683 | 690 | ||
| 691 | + | func TestHandler_RoutesPost_ImageNotFound(t *testing.T) { | |
| 692 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 693 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 694 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 695 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 696 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 697 | + | ||
| 698 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) | |
| 699 | + | ||
| 700 | + | tests := []struct { | |
| 701 | + | name string | |
| 702 | + | image string | |
| 703 | + | body string | |
| 704 | + | manifestName string | |
| 705 | + | manifestRef string | |
| 706 | + | }{ | |
| 707 | + | { | |
| 708 | + | name: "tagged image not found", | |
| 709 | + | image: "myapp:latest", | |
| 710 | + | body: `{"domain": "app.example.com", "image": "myapp:latest"}`, | |
| 711 | + | manifestName: "myapp", | |
| 712 | + | manifestRef: "latest", | |
| 713 | + | }, | |
| 714 | + | { | |
| 715 | + | name: "digest image not found", | |
| 716 | + | image: "myapp@sha256:abc123", | |
| 717 | + | body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`, | |
| 718 | + | manifestName: "myapp", | |
| 719 | + | manifestRef: "sha256:abc123", | |
| 720 | + | }, | |
| 721 | + | { | |
| 722 | + | name: "implicit latest tag not found", | |
| 723 | + | image: "myapp", | |
| 724 | + | body: `{"domain": "app.example.com", "image": "myapp"}`, | |
| 725 | + | manifestName: "myapp", | |
| 726 | + | manifestRef: "latest", | |
| 727 | + | }, | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | for _, tt := range tests { | |
| 731 | + | t.Run(tt.name, func(t *testing.T) { | |
| 732 | + | registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound) | |
| 733 | + | ||
| 734 | + | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |
| 735 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| 736 | + | rec := httptest.NewRecorder() | |
| 737 | + | ||
| 738 | + | handler.ServeHTTP(rec, req) | |
| 739 | + | ||
| 740 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 741 | + | assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image)) | |
| 742 | + | }) | |
| 743 | + | } | |
| 744 | + | } | |
| 745 | + | ||
| 684 | 746 | func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | |
| 685 | 747 | configSvc := inmocks.NewMockConfigService(t) | |
| 686 | 748 | authSvc := inmocks.NewMockAuthService(t) | |
| 687 | 749 | containerSvc := inmocks.NewMockContainerService(t) | |
| 688 | 750 | secretSvc := inmocks.NewMockSecretService(t) | |
| 689 | 751 | ||
| 690 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 752 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 691 | 753 | ||
| 692 | 754 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | |
| 693 | 755 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -704,7 +766,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||
| 704 | 766 | containerSvc := inmocks.NewMockContainerService(t) | |
| 705 | 767 | secretSvc := inmocks.NewMockSecretService(t) | |
| 706 | 768 | ||
| 707 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 769 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 708 | 770 | ||
| 709 | 771 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | |
| 710 | 772 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -721,7 +783,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||
| 721 | 783 | containerSvc := inmocks.NewMockContainerService(t) | |
| 722 | 784 | secretSvc := inmocks.NewMockSecretService(t) | |
| 723 | 785 | ||
| 724 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 786 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 725 | 787 | ||
| 726 | 788 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | |
| 727 | 789 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -738,7 +800,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||
| 738 | 800 | containerSvc := inmocks.NewMockContainerService(t) | |
| 739 | 801 | secretSvc := inmocks.NewMockSecretService(t) | |
| 740 | 802 | ||
| 741 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 803 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 742 | 804 | ||
| 743 | 805 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | |
| 744 | 806 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -755,7 +817,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||
| 755 | 817 | containerSvc := inmocks.NewMockContainerService(t) | |
| 756 | 818 | secretSvc := inmocks.NewMockSecretService(t) | |
| 757 | 819 | ||
| 758 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 820 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 759 | 821 | ||
| 760 | 822 | tests := []struct { | |
| 761 | 823 | method string | |
| @@ -788,7 +850,7 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 788 | 850 | containerSvc := inmocks.NewMockContainerService(t) | |
| 789 | 851 | secretSvc := inmocks.NewMockSecretService(t) | |
| 790 | 852 | ||
| 791 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | |
| 853 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 792 | 854 | ||
| 793 | 855 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | |
| 794 | 856 | 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 | ||||
| @@ -179,6 +183,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||||
| 179 | t.Run(tt.name, func(t *testing.T) { | 183 | t.Run(tt.name, func(t *testing.T) { | ||
| 180 | if tt.wantStatus == http.StatusOK { | 184 | if tt.wantStatus == http.StatusOK { | ||
| 181 | configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | 185 | configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | ||
| 186 | + | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe() | |||
| 182 | } | 187 | } | ||
| 183 | 188 | ||||
| 184 | req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON)) | 189 | req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON)) | ||
| @@ -198,7 +203,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||||
| 198 | containerSvc := inmocks.NewMockContainerService(t) | 203 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 199 | secretSvc := inmocks.NewMockSecretService(t) | 204 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 200 | 205 | ||||
| 201 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 206 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 202 | 207 | ||||
| 203 | tests := []struct { | 208 | tests := []struct { | ||
| 204 | name string | 209 | name string | ||
| @@ -242,7 +247,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||||
| 242 | containerSvc := inmocks.NewMockContainerService(t) | 247 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 243 | secretSvc := inmocks.NewMockSecretService(t) | 248 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 244 | 249 | ||||
| 245 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 250 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 246 | 251 | ||||
| 247 | tests := []struct { | 252 | tests := []struct { | ||
| 248 | name string | 253 | name string | ||
| @@ -299,7 +304,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||||
| 299 | containerSvc := inmocks.NewMockContainerService(t) | 304 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 300 | secretSvc := inmocks.NewMockSecretService(t) | 305 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 301 | 306 | ||||
| 302 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 307 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 303 | 308 | ||||
| 304 | secretsJSON := `{"API_KEY": "secret123"}` | 309 | secretsJSON := `{"API_KEY": "secret123"}` | ||
| 305 | 310 | ||||
| @@ -348,7 +353,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||||
| 348 | containerSvc := inmocks.NewMockContainerService(t) | 353 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 349 | secretSvc := inmocks.NewMockSecretService(t) | 354 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 350 | 355 | ||||
| 351 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 356 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 352 | 357 | ||||
| 353 | tests := []struct { | 358 | tests := []struct { | ||
| 354 | name string | 359 | name string | ||
| @@ -392,7 +397,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||||
| 392 | containerSvc := inmocks.NewMockContainerService(t) | 397 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 393 | secretSvc := inmocks.NewMockSecretService(t) | 398 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 394 | 399 | ||||
| 395 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 400 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 396 | 401 | ||||
| 397 | tests := []struct { | 402 | tests := []struct { | ||
| 398 | name string | 403 | name string | ||
| @@ -446,7 +451,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||||
| 446 | containerSvc := inmocks.NewMockContainerService(t) | 451 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 447 | secretSvc := inmocks.NewMockSecretService(t) | 452 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 448 | 453 | ||||
| 449 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 454 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 450 | 455 | ||||
| 451 | tests := []struct { | 456 | tests := []struct { | ||
| 452 | name string | 457 | name string | ||
| @@ -503,7 +508,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||||
| 503 | containerSvc := inmocks.NewMockContainerService(t) | 508 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 504 | secretSvc := inmocks.NewMockSecretService(t) | 509 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 505 | 510 | ||||
| 506 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 511 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 507 | 512 | ||||
| 508 | tests := []struct { | 513 | tests := []struct { | ||
| 509 | name string | 514 | name string | ||
| @@ -557,7 +562,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||||
| 557 | containerSvc := inmocks.NewMockContainerService(t) | 562 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 558 | secretSvc := inmocks.NewMockSecretService(t) | 563 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 559 | 564 | ||||
| 560 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 565 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 561 | 566 | ||||
| 562 | expectedRoutes := []domain.Route{ | 567 | expectedRoutes := []domain.Route{ | ||
| 563 | {Domain: "app1.example.com", Image: "app1:latest"}, | 568 | {Domain: "app1.example.com", Image: "app1:latest"}, | ||
| @@ -592,7 +597,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||||
| 592 | containerSvc := inmocks.NewMockContainerService(t) | 597 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 593 | secretSvc := inmocks.NewMockSecretService(t) | 598 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 594 | 599 | ||||
| 595 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 600 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 596 | 601 | ||||
| 597 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | 602 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | ||
| 598 | Domain: "app.example.com", Image: "app:latest", | 603 | Domain: "app.example.com", Image: "app:latest", | ||
| @@ -623,7 +628,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||||
| 623 | containerSvc := inmocks.NewMockContainerService(t) | 628 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 624 | secretSvc := inmocks.NewMockSecretService(t) | 629 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 625 | 630 | ||||
| 626 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 631 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 627 | 632 | ||||
| 628 | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | 633 | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | ||
| 629 | 634 | ||||
| @@ -665,10 +670,12 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||||
| 665 | authSvc := inmocks.NewMockAuthService(t) | 670 | authSvc := inmocks.NewMockAuthService(t) | ||
| 666 | containerSvc := inmocks.NewMockContainerService(t) | 671 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 667 | secretSvc := inmocks.NewMockSecretService(t) | 672 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 673 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 668 | 674 | ||||
| 669 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 675 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) |
| 670 | 676 | ||||
| 671 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | 677 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | ||
| 678 | + | registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe() | |||
| 672 | 679 | ||||
| 673 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | 680 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | ||
| 674 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 681 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -681,13 +688,68 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||||
| 681 | } | 688 | } | ||
| 682 | } | 689 | } | ||
| 683 | 690 | ||||
| 691 | + | func TestHandler_RoutesPost_ImageNotFound(t *testing.T) { | |||
| 692 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 693 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 694 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 695 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 696 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 697 | + | ||||
| 698 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger()) | |||
| 699 | + | ||||
| 700 | + | tests := []struct { | |||
| 701 | + | name string | |||
| 702 | + | image string | |||
| 703 | + | body string | |||
| 704 | + | manifestName string | |||
| 705 | + | manifestRef string | |||
| 706 | + | }{ | |||
| 707 | + | { | |||
| 708 | + | name: "tagged image not found", | |||
| 709 | + | image: "myapp:latest", | |||
| 710 | + | body: `{"domain": "app.example.com", "image": "myapp:latest"}`, | |||
| 711 | + | manifestName: "myapp", | |||
| 712 | + | manifestRef: "latest", | |||
| 713 | + | }, | |||
| 714 | + | { | |||
| 715 | + | name: "digest image not found", | |||
| 716 | + | image: "myapp@sha256:abc123", | |||
| 717 | + | body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`, | |||
| 718 | + | manifestName: "myapp", | |||
| 719 | + | manifestRef: "sha256:abc123", | |||
| 720 | + | }, | |||
| 721 | + | { | |||
| 722 | + | name: "implicit latest tag not found", | |||
| 723 | + | image: "myapp", | |||
| 724 | + | body: `{"domain": "app.example.com", "image": "myapp"}`, | |||
| 725 | + | manifestName: "myapp", | |||
| 726 | + | manifestRef: "latest", | |||
| 727 | + | }, | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | for _, tt := range tests { | |||
| 731 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 732 | + | registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound) | |||
| 733 | + | ||||
| 734 | + | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |||
| 735 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |||
| 736 | + | rec := httptest.NewRecorder() | |||
| 737 | + | ||||
| 738 | + | handler.ServeHTTP(rec, req) | |||
| 739 | + | ||||
| 740 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 741 | + | assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image)) | |||
| 742 | + | }) | |||
| 743 | + | } | |||
| 744 | + | } | |||
| 745 | + | ||||
| 684 | func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | 746 | func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | ||
| 685 | configSvc := inmocks.NewMockConfigService(t) | 747 | configSvc := inmocks.NewMockConfigService(t) | ||
| 686 | authSvc := inmocks.NewMockAuthService(t) | 748 | authSvc := inmocks.NewMockAuthService(t) | ||
| 687 | containerSvc := inmocks.NewMockContainerService(t) | 749 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 688 | secretSvc := inmocks.NewMockSecretService(t) | 750 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 689 | 751 | ||||
| 690 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 752 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 691 | 753 | ||||
| 692 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | 754 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | ||
| 693 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 755 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -704,7 +766,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||||
| 704 | containerSvc := inmocks.NewMockContainerService(t) | 766 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 705 | secretSvc := inmocks.NewMockSecretService(t) | 767 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 706 | 768 | ||||
| 707 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 769 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 708 | 770 | ||||
| 709 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | 771 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | ||
| 710 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 772 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -721,7 +783,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||||
| 721 | containerSvc := inmocks.NewMockContainerService(t) | 783 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 722 | secretSvc := inmocks.NewMockSecretService(t) | 784 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 723 | 785 | ||||
| 724 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 786 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 725 | 787 | ||||
| 726 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | 788 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | ||
| 727 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | 789 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | ||
| @@ -738,7 +800,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||||
| 738 | containerSvc := inmocks.NewMockContainerService(t) | 800 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 739 | secretSvc := inmocks.NewMockSecretService(t) | 801 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 740 | 802 | ||||
| 741 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 803 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 742 | 804 | ||||
| 743 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | 805 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | ||
| 744 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | 806 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | ||
| @@ -755,7 +817,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||||
| 755 | containerSvc := inmocks.NewMockContainerService(t) | 817 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 756 | secretSvc := inmocks.NewMockSecretService(t) | 818 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 757 | 819 | ||||
| 758 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 820 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 759 | 821 | ||||
| 760 | tests := []struct { | 822 | tests := []struct { | ||
| 761 | method string | 823 | method string | ||
| @@ -788,7 +850,7 @@ func TestHandler_NotFound(t *testing.T) { | |||||
| 788 | containerSvc := inmocks.NewMockContainerService(t) | 850 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 789 | secretSvc := inmocks.NewMockSecretService(t) | 851 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 790 | 852 | ||||
| 791 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger()) | 853 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) |
| 792 | 854 | ||||
| 793 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | 855 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | ||
| 794 | req = req.WithContext(ctxWithScopes("admin:*:*")) | 856 | 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 +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package validation | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // ParseImageReference parses an image reference into name and tag/digest. | |
| 6 | + | // Supports formats: | |
| 7 | + | // - image:tag (default tag is "latest") | |
| 8 | + | // - image@sha256:... (digest) | |
| 9 | + | // - image (defaults to "latest") | |
| 10 | + | // - registry.example.com/image:tag | |
| 11 | + | // - registry.example.com/image@sha256:... | |
| 12 | + | // | |
| 13 | + | // Returns: | |
| 14 | + | // - name: the image name (including registry if specified) | |
| 15 | + | // - reference: the tag (e.g., "latest") or digest (e.g., "sha256:...") | |
| 16 | + | func ParseImageReference(imageRef string) (string, string) { | |
| 17 | + | // Check for digest reference (@sha256:...) | |
| 18 | + | if idx := strings.Index(imageRef, "@sha256:"); idx != -1 { | |
| 19 | + | return imageRef[:idx], imageRef[idx+1:] // Return name and full digest | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Check for tag reference (:tag) | |
| 23 | + | if idx := strings.Index(imageRef, ":"); idx != -1 { | |
| 24 | + | // Check if this is actually a port number (e.g., registry:5000/image) | |
| 25 | + | // by looking for a slash before the colon | |
| 26 | + | if slashIdx := strings.Index(imageRef, "/"); slashIdx != -1 && slashIdx > idx { | |
| 27 | + | // Slash comes after colon, so colon is part of registry port | |
| 28 | + | // Look for another colon for the tag | |
| 29 | + | if tagIdx := strings.Index(imageRef[slashIdx:], ":"); tagIdx != -1 { | |
| 30 | + | actualTagIdx := slashIdx + tagIdx | |
| 31 | + | return imageRef[:actualTagIdx], imageRef[actualTagIdx+1:] | |
| 32 | + | } | |
| 33 | + | return imageRef, "latest" | |
| 34 | + | } | |
| 35 | + | return imageRef[:idx], imageRef[idx+1:] // Return name and tag | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Default to "latest" tag | |
| 39 | + | return imageRef, "latest" | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package validation | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // ParseImageReference parses an image reference into name and tag/digest. | |||
| 6 | + | // Supports formats: | |||
| 7 | + | // - image:tag (default tag is "latest") | |||
| 8 | + | // - image@sha256:... (digest) | |||
| 9 | + | // - image (defaults to "latest") | |||
| 10 | + | // - registry.example.com/image:tag | |||
| 11 | + | // - registry.example.com/image@sha256:... | |||
| 12 | + | // | |||
| 13 | + | // Returns: | |||
| 14 | + | // - name: the image name (including registry if specified) | |||
| 15 | + | // - reference: the tag (e.g., "latest") or digest (e.g., "sha256:...") | |||
| 16 | + | func ParseImageReference(imageRef string) (string, string) { | |||
| 17 | + | // Check for digest reference (@sha256:...) | |||
| 18 | + | if idx := strings.Index(imageRef, "@sha256:"); idx != -1 { | |||
| 19 | + | return imageRef[:idx], imageRef[idx+1:] // Return name and full digest | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // Check for tag reference (:tag) | |||
| 23 | + | if idx := strings.Index(imageRef, ":"); idx != -1 { | |||
| 24 | + | // Check if this is actually a port number (e.g., registry:5000/image) | |||
| 25 | + | // by looking for a slash before the colon | |||
| 26 | + | if slashIdx := strings.Index(imageRef, "/"); slashIdx != -1 && slashIdx > idx { | |||
| 27 | + | // Slash comes after colon, so colon is part of registry port | |||
| 28 | + | // Look for another colon for the tag | |||
| 29 | + | if tagIdx := strings.Index(imageRef[slashIdx:], ":"); tagIdx != -1 { | |||
| 30 | + | actualTagIdx := slashIdx + tagIdx | |||
| 31 | + | return imageRef[:actualTagIdx], imageRef[actualTagIdx+1:] | |||
| 32 | + | } | |||
| 33 | + | return imageRef, "latest" | |||
| 34 | + | } | |||
| 35 | + | return imageRef[:idx], imageRef[idx+1:] // Return name and tag | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Default to "latest" tag | |||
| 39 | + | return imageRef, "latest" | |||
| 40 | + | } | |||
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 | + | } | |||