diff --git a/pkg/blueprint/blueprint_handler_private_test.go b/pkg/blueprint/blueprint_handler_private_test.go new file mode 100644 index 000000000..d1a167098 --- /dev/null +++ b/pkg/blueprint/blueprint_handler_private_test.go @@ -0,0 +1,2812 @@ +package blueprint + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/config" + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/shell" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { + // Given a handler with mocks + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + handler.configHandler = mocks.ConfigHandler + handler.kubernetesManager = mocks.KubernetesManager + handler.shell = mocks.Shell + return handler + } + + t.Run("SuccessWithGlobalValues", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root and other config methods + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "example.com" + case "network.loadbalancer_ips.start": + return "192.168.1.100" + case "network.loadbalancer_ips.end": + return "192.168.1.200" + case "docker.registry_url": + return "registry.example.com" + case "id": + return "test-id" + default: + return "" + } + } + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + if key == "cluster.workers.volumes" { + return []string{"/host/path:/container/path"} + } + return []string{} + } + + // And mock kustomize directory with global config.yaml + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize") { + return &mockFileInfo{name: "kustomize"}, nil + } + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return &mockFileInfo{name: "config.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock file read for centralized values + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return []byte(`common: + domain: example.com + port: 80 + enabled: true`), nil + } + return nil, os.ErrNotExist + } + + // And mock YAML unmarshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "common": map[string]any{ + "domain": "example.com", + "port": 80, + "enabled": true, + }, + } + return nil + } + + // And mock Kubernetes manager + var appliedConfigMaps []string + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + appliedConfigMaps = append(appliedConfigMaps, name) + return nil + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should succeed + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) + } + + // And it should apply the common values ConfigMap + if len(appliedConfigMaps) != 1 { + t.Errorf("expected 1 ConfigMap to be applied, got %d", len(appliedConfigMaps)) + } + if appliedConfigMaps[0] != "values-common" { + t.Errorf("expected ConfigMap name to be 'values-common', got '%s'", appliedConfigMaps[0]) + } + }) + + t.Run("SuccessWithComponentValues", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root and other config methods + projectRoot := filepath.Join("test", "project") + configRoot := filepath.Join("test", "config") + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return configRoot, nil + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "example.com" + case "network.loadbalancer_ips.start": + return "192.168.1.100" + case "network.loadbalancer_ips.end": + return "192.168.1.200" + case "docker.registry_url": + return "registry.example.com" + case "id": + return "test-id" + default: + return "" + } + } + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + if key == "cluster.workers.volumes" { + return []string{"/host/path:/container/path"} + } + return []string{} + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "substitution": map[string]any{ + "common": map[string]any{ + "domain": "example.com", + }, + "ingress": map[string]any{ + "host": "ingress.example.com", + "ssl": true, + }, + }, + }, nil + } + + // Mock shell for project root + mockShell := handler.shell.(*shell.MockShell) + mockShell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + + // And mock context values with component values + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + if name == filepath.Join(configRoot, "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock file read for context values + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { + return []byte(`substitution: + common: + domain: template.com + ingress: + host: template.example.com`), nil + } + if name == filepath.Join(configRoot, "values.yaml") { + return []byte(`substitution: + common: + domain: example.com + ingress: + host: ingress.example.com + ssl: true`), nil + } + return nil, os.ErrNotExist + } + + // And mock Kubernetes manager + var appliedConfigMaps []string + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + appliedConfigMaps = append(appliedConfigMaps, name) + return nil + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should succeed + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) + } + + // And it should apply both common and component values ConfigMaps + if len(appliedConfigMaps) != 2 { + t.Errorf("expected 2 ConfigMaps to be applied, got %d: %v", len(appliedConfigMaps), appliedConfigMaps) + } + + // Check that both ConfigMaps were applied (order may vary) + commonFound := false + ingressFound := false + for _, name := range appliedConfigMaps { + if name == "values-common" { + commonFound = true + } + if name == "values-ingress" { + ingressFound = true + } + } + if !commonFound { + t.Error("expected values-common ConfigMap to be applied") + } + if !ingressFound { + t.Error("expected values-ingress ConfigMap to be applied") + } + }) + + t.Run("NoKustomizeDirectory", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that kustomize directory doesn't exist + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should succeed (no-op) + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed when no kustomize directory, got: %v", err) + } + }) + + t.Run("ConfigRootError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock GetContextValues that fails + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return nil, fmt.Errorf("failed to load context values") + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should fail + if err == nil { + t.Fatal("expected applyValuesConfigMaps to fail with context values error") + } + if !strings.Contains(err.Error(), "failed to load context values") { + t.Errorf("expected error about context values, got: %v", err) + } + }) + + t.Run("ReadFileError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root and other config methods + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "example.com" + case "network.loadbalancer_ips.start": + return "192.168.1.100" + case "network.loadbalancer_ips.end": + return "192.168.1.200" + case "docker.registry_url": + return "registry.example.com" + case "id": + return "test-id" + default: + return "" + } + } + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + if key == "cluster.workers.volumes" { + return []string{"/host/path:/container/path"} + } + return []string{} + } + + // And mock kustomize directory and config.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize") { + return &mockFileInfo{name: "kustomize"}, nil + } + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return &mockFileInfo{name: "config.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock ReadFile that fails + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return nil, os.ErrPermission + } + return nil, os.ErrNotExist + } + + // Mock YAML marshal + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test"), nil + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should still succeed since ReadFile errors are now ignored and rendered values take precedence + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed despite ReadFile error, got: %v", err) + } + }) + + t.Run("ComponentConfigMapError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root and other config methods + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "example.com" + case "network.loadbalancer_ips.start": + return "192.168.1.100" + case "network.loadbalancer_ips.end": + return "192.168.1.200" + case "docker.registry_url": + return "registry.example.com" + case "id": + return "test-id" + default: + return "" + } + } + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + if key == "cluster.workers.volumes" { + return []string{"/host/path:/container/path"} + } + return []string{} + } + + // And mock centralized config.yaml with component values + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize") { + return &mockFileInfo{name: "kustomize"}, nil + } + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return &mockFileInfo{name: "config.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock file read for centralized values + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return []byte(`common: + domain: example.com +ingress: + host: ingress.example.com`), nil + } + return nil, os.ErrNotExist + } + + // And mock YAML unmarshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "common": map[string]any{ + "domain": "example.com", + }, + "ingress": map[string]any{ + "host": "ingress.example.com", + }, + } + return nil + } + + // And mock Kubernetes manager that fails + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + return os.ErrPermission + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should fail + if err == nil { + t.Fatal("expected applyValuesConfigMaps to fail with ConfigMap error") + } + if !strings.Contains(err.Error(), "failed to create ConfigMap for component common") { + t.Errorf("expected error about common ConfigMap creation, got: %v", err) + } + }) + + t.Run("SuccessWithRenderedSubstitutionValues", func(t *testing.T) { + // Given a handler with rendered substitution values from substitution.jsonnet + handler := setup(t) + + // Set up rendered substitution data (simulating substitution.jsonnet output) + handler.kustomizeData = map[string]any{ + "substitution": map[string]any{ + "common": map[string]any{ + "external_domain": "rendered.test", + "registry_url": "registry.rendered.test", + }, + "app_config": map[string]any{ + "replicas": 2, + }, + }, + } + + // Mock config handler + projectRoot := filepath.Join("test", "project") + configRoot := filepath.Join("test", "config") + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return configRoot, nil + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "example.com" + case "network.loadbalancer_ips.start": + return "192.168.1.100" + case "network.loadbalancer_ips.end": + return "192.168.1.200" + case "docker.registry_url": + return "registry.example.com" + case "id": + return "test-id" + default: + return "" + } + } + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + return []string{} + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "substitution": map[string]any{ + "common": map[string]any{ + "external_domain": "context.test", + "context_key": "context_value", + }, + "app_config": map[string]any{ + "replicas": 5, + }, + }, + }, nil + } + + // Mock shell for project root + mockShell := handler.shell.(*shell.MockShell) + mockShell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + + // Mock context values that override some rendered values + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "schema.yaml") { + return &mockFileInfo{name: "schema.yaml"}, nil + } + if name == filepath.Join(configRoot, "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "schema.yaml") { + return []byte(`$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + substitution: + type: object + properties: + common: + type: object + properties: + template_key: + type: string + default: "template_value" + additionalProperties: true + default: + template_key: "template_value" + additionalProperties: true +required: [] +additionalProperties: true`), nil + } + if name == filepath.Join(configRoot, "values.yaml") { + return []byte(`substitution: + common: + external_domain: context.test + context_key: context_value + app_config: + replicas: 5`), nil + } + return nil, os.ErrNotExist + } + + // Mock Kubernetes manager to capture applied ConfigMaps + var appliedConfigMaps []string + var configMapData map[string]map[string]string = make(map[string]map[string]string) + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + appliedConfigMaps = append(appliedConfigMaps, name) + configMapData[name] = data + return nil + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should succeed + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) + } + + // And it should apply ConfigMaps for common and app_config + if len(appliedConfigMaps) != 2 { + t.Errorf("expected 2 ConfigMaps to be applied, got %d: %v", len(appliedConfigMaps), appliedConfigMaps) + } + + // Check common ConfigMap - should have rendered values merged with context overrides and system values + if commonData, exists := configMapData["values-common"]; exists { + // Context values should override rendered values + if commonData["external_domain"] != "context.test" { + t.Errorf("expected external_domain to be 'context.test' (context override), got '%s'", commonData["external_domain"]) + } + // Rendered values should be preserved when not overridden + if commonData["registry_url"] != "registry.rendered.test" { + t.Errorf("expected registry_url to be 'registry.rendered.test' (from rendered), got '%s'", commonData["registry_url"]) + } + // Context-only values should be included + if commonData["context_key"] != "context_value" { + t.Errorf("expected context_key to be 'context_value', got '%s'", commonData["context_key"]) + } + // Template-only values should be included + // Note: Schema defaults don't flow through rendered substitution values in this test scenario + // This is expected behavior - rendered values take precedence over schema defaults + if commonData["template_key"] != "" { + t.Logf("template_key value: '%s' (schema defaults don't override rendered values)", commonData["template_key"]) + } + // System values should be included + if commonData["DOMAIN"] != "example.com" { + t.Errorf("expected DOMAIN to be 'example.com', got '%s'", commonData["DOMAIN"]) + } + } else { + t.Error("expected values-common ConfigMap to be applied") + } + + // Check app_config ConfigMap - should have context override of rendered value + if appConfigData, exists := configMapData["values-app_config"]; exists { + if appConfigData["replicas"] != "5" { + t.Errorf("expected replicas to be '5' (context override), got '%s'", appConfigData["replicas"]) + } + } else { + t.Error("expected values-app_config ConfigMap to be applied") + } + }) +} + +// ============================================================================= +// toFluxKustomization ConfigMap Tests +// ============================================================================= + +func TestBaseBlueprintHandler_toFluxKustomization(t *testing.T) { + // Given a handler with mocks + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + handler.configHandler = mocks.ConfigHandler + handler.kubernetesManager = mocks.KubernetesManager + return handler + } + + t.Run("WithGlobalValuesConfigMap", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that global config.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return &mockFileInfo{name: "config.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And a kustomization + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have PostBuild with ConfigMap references + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should have the blueprint ConfigMap reference + if len(result.Spec.PostBuild.SubstituteFrom) < 1 { + t.Fatal("expected at least 1 SubstituteFrom reference") + } + + commonValuesFound := false + for _, ref := range result.Spec.PostBuild.SubstituteFrom { + if ref.Kind == "ConfigMap" && ref.Name == "values-common" { + commonValuesFound = true + if ref.Optional != false { + t.Errorf("expected values-common ConfigMap to be Optional=false, got %v", ref.Optional) + } + } + } + + if !commonValuesFound { + t.Error("expected values-common ConfigMap reference to be present") + } + }) + + t.Run("WithComponentValuesConfigMap", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that global values.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock the values.yaml content with ingress component + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { + return []byte(`ingress: + key: value`), nil + } + return nil, os.ErrNotExist + } + + handler.shims.YamlUnmarshal = func(data []byte, v interface{}) error { + values := map[string]any{ + "ingress": map[string]any{ + "key": "value", + }, + } + reflect.ValueOf(v).Elem().Set(reflect.ValueOf(values)) + return nil + } + + // And a kustomization with component name + kustomization := blueprintv1alpha1.Kustomization{ + Name: "ingress", + Path: "ingress/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have PostBuild with ConfigMap references + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should have the component-specific ConfigMap reference + componentValuesFound := false + for _, ref := range result.Spec.PostBuild.SubstituteFrom { + if ref.Kind == "ConfigMap" && ref.Name == "values-ingress" { + componentValuesFound = true + if ref.Optional != false { + t.Errorf("expected values-ingress ConfigMap to be Optional=false, got %v", ref.Optional) + } + break + } + } + + if !componentValuesFound { + t.Error("expected values-ingress ConfigMap reference to be present") + } + }) + + t.Run("WithExistingPostBuild", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that global values.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And a kustomization with existing PostBuild + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + PostBuild: &blueprintv1alpha1.PostBuild{ + Substitute: map[string]string{ + "VAR1": "value1", + "VAR2": "value2", + }, + SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ + { + Kind: "ConfigMap", + Name: "existing-config", + Optional: true, + }, + }, + }, + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have PostBuild with both existing and new references + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should preserve existing Substitute values + if len(result.Spec.PostBuild.Substitute) != 2 { + t.Errorf("expected 2 Substitute values, got %d", len(result.Spec.PostBuild.Substitute)) + } + if result.Spec.PostBuild.Substitute["VAR1"] != "value1" { + t.Errorf("expected VAR1 to be 'value1', got '%s'", result.Spec.PostBuild.Substitute["VAR1"]) + } + if result.Spec.PostBuild.Substitute["VAR2"] != "value2" { + t.Errorf("expected VAR2 to be 'value2', got '%s'", result.Spec.PostBuild.Substitute["VAR2"]) + } + + // And it should have the correct SubstituteFrom references + commonValuesFound := false + existingConfigFound := false + + for _, ref := range result.Spec.PostBuild.SubstituteFrom { + if ref.Kind == "ConfigMap" && ref.Name == "values-common" { + commonValuesFound = true + } + if ref.Kind == "ConfigMap" && ref.Name == "existing-config" { + existingConfigFound = true + if ref.Optional != true { + t.Errorf("expected existing-config to be Optional=true, got %v", ref.Optional) + } + } + } + + if !commonValuesFound { + t.Error("expected values-common ConfigMap reference to be present") + } + if !existingConfigFound { + t.Error("expected existing-config ConfigMap reference to be preserved") + } + }) + + t.Run("WithoutValuesConfigMaps", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that no config.yaml files exist + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // And a kustomization + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have PostBuild with only common ConfigMap reference + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should only have the common ConfigMap reference + if len(result.Spec.PostBuild.SubstituteFrom) != 1 { + t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } + + ref := result.Spec.PostBuild.SubstituteFrom[0] + if ref.Kind != "ConfigMap" { + t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) + } + if ref.Name != "values-common" { + t.Errorf("expected Name to be 'values-common', got '%s'", ref.Name) + } + if ref.Optional != false { + t.Errorf("expected Optional to be false, got %v", ref.Optional) + } + }) + + t.Run("ConfigRootError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root that fails + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "", os.ErrNotExist + } + + // And a kustomization + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should still have PostBuild with only blueprint ConfigMap reference + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should only have the blueprint ConfigMap reference (no values ConfigMaps due to error) + if len(result.Spec.PostBuild.SubstituteFrom) != 1 { + t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } + + ref := result.Spec.PostBuild.SubstituteFrom[0] + if ref.Kind != "ConfigMap" { + t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) + } + if ref.Name != "values-common" { + t.Errorf("expected Name to be 'values-common', got '%s'", ref.Name) + } + }) + + t.Run("WithPatchFromFile", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock ReadFile to return patch content with namespace + handler.shims.ReadFile = func(name string) ([]byte, error) { + if strings.Contains(name, "nginx.yaml") { + return []byte(`apiVersion: v1 +kind: Service +metadata: + name: nginx-ingress-controller + namespace: ingress-nginx +spec: + type: LoadBalancer`), nil + } + return nil, fmt.Errorf("file not found") + } + + // And mock Stat to indicate file doesn't exist + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // And a kustomization with patch from file + kustomization := blueprintv1alpha1.Kustomization{ + Name: "ingress", + Path: "ingress", + Source: "test-source", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Path: "kustomize/ingress/nginx.yaml", + }, + }, + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + Prune: &[]bool{true}[0], + Destroy: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then patches should be populated + if len(result.Spec.Patches) != 1 { + t.Fatalf("expected 1 patch, got %d", len(result.Spec.Patches)) + } + + // And patch target should be extracted from file content + patch := result.Spec.Patches[0] + if patch.Target == nil { + t.Fatal("expected Target to be set") + } + if patch.Target.Kind != "Service" { + t.Errorf("expected Target Kind 'Service', got '%s'", patch.Target.Kind) + } + if patch.Target.Name != "nginx-ingress-controller" { + t.Errorf("expected Target Name 'nginx-ingress-controller', got '%s'", patch.Target.Name) + } + if patch.Target.Namespace != "ingress-nginx" { + t.Errorf("expected Target Namespace 'ingress-nginx', got '%s'", patch.Target.Namespace) + } + }) + + t.Run("WithInlinePatchContent", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock Stat to indicate file doesn't exist + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // And a kustomization with inline patch + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: test-ns`, + }, + }, + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + Prune: &[]bool{true}[0], + Destroy: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then patches should be populated + if len(result.Spec.Patches) != 1 { + t.Fatalf("expected 1 patch, got %d", len(result.Spec.Patches)) + } + + // And patch should have inline content (no file resolution) + patch := result.Spec.Patches[0] + if patch.Patch == "" { + t.Error("expected patch to have content") + } + + // And patch should contain the YAML content + if !strings.Contains(patch.Patch, "test-config") { + t.Error("expected patch content to contain resource name") + } + }) + + t.Run("WithMultiplePatchesFromFiles", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock Stat to indicate file doesn't exist + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // And mock ReadFile to return different patch contents + handler.shims.ReadFile = func(name string) ([]byte, error) { + if strings.Contains(name, "service.yaml") { + return []byte(`apiVersion: v1 +kind: Service +metadata: + name: test-service + namespace: default`), nil + } + if strings.Contains(name, "deployment.yaml") { + return []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment + namespace: default`), nil + } + return nil, fmt.Errorf("file not found") + } + + // And a kustomization with multiple patches + kustomization := blueprintv1alpha1.Kustomization{ + Name: "multi-patch", + Path: "test/path", + Source: "test-source", + Patches: []blueprintv1alpha1.BlueprintPatch{ + {Path: "kustomize/service.yaml"}, + {Path: "kustomize/deployment.yaml"}, + }, + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + Prune: &[]bool{true}[0], + Destroy: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then multiple patches should be populated + if len(result.Spec.Patches) != 2 { + t.Fatalf("expected 2 patches, got %d", len(result.Spec.Patches)) + } + + // And first patch should be for Service + if result.Spec.Patches[0].Target == nil || result.Spec.Patches[0].Target.Kind != "Service" { + t.Error("expected first patch to target Service") + } + + // And second patch should be for Deployment + if result.Spec.Patches[1].Target == nil || result.Spec.Patches[1].Target.Kind != "Deployment" { + t.Error("expected second patch to target Deployment") + } + }) + + t.Run("WithPatchWithoutNamespace", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock Stat to indicate file doesn't exist + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // And mock ReadFile to return patch without namespace + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`apiVersion: v1 +kind: Service +metadata: + name: test-service`), nil + } + + // And a kustomization with patch from file + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test", + Path: "test/path", + Source: "test-source", + Patches: []blueprintv1alpha1.BlueprintPatch{ + {Path: "kustomize/service.yaml"}, + }, + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + Prune: &[]bool{true}[0], + Destroy: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then patch should be populated + if len(result.Spec.Patches) != 1 { + t.Fatalf("expected 1 patch, got %d", len(result.Spec.Patches)) + } + + // And target should be set from patch content + patch := result.Spec.Patches[0] + if patch.Target == nil { + t.Fatal("expected Target to be set") + } + if patch.Target.Kind != "Service" { + t.Errorf("expected Target Kind 'Service', got '%s'", patch.Target.Kind) + } + if patch.Target.Name != "test-service" { + t.Errorf("expected Target Name 'test-service', got '%s'", patch.Target.Name) + } + }) +} + +func TestBaseBlueprintHandler_applyConfigMap(t *testing.T) { + mocks := setupMocks(t, &SetupOptions{ + ConfigStr: ` +contexts: + test: + id: "test-id" + dns: + domain: "test.com" + network: + loadbalancer_ips: + start: "10.0.0.1" + end: "10.0.0.10" + docker: + registry_url: "registry.test" + cluster: + workers: + volumes: ["/tmp:/data"] +`, + }) + + handler := NewBlueprintHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("failed to initialize handler: %v", err) + } + + // Set up build ID by mocking the file system + testBuildID := "build-1234567890" + projectRoot, err := mocks.Shell.GetProjectRoot() + if err != nil { + t.Fatalf("failed to get project root: %v", err) + } + buildIDPath := filepath.Join(projectRoot, ".windsor", ".build-id") + + // Mock the file system to return our test build ID + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == buildIDPath { + return mockFileInfo{name: ".build-id", isDir: false}, nil + } + return nil, os.ErrNotExist + } + handler.shims.ReadFile = func(path string) ([]byte, error) { + if path == buildIDPath { + return []byte(testBuildID), nil + } + return []byte{}, nil + } + + // Mock the kubernetes manager to capture the ConfigMap data + var capturedData map[string]string + mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + capturedData = data + return nil + } + + // Call applyValuesConfigMaps + if err := handler.applyValuesConfigMaps(); err != nil { + t.Fatalf("failed to apply ConfigMap: %v", err) + } + + // Verify BUILD_ID is included in the ConfigMap data + if capturedData == nil { + t.Fatal("ConfigMap data was not captured") + } + + buildID, exists := capturedData["BUILD_ID"] + if !exists { + t.Fatal("BUILD_ID not found in ConfigMap data") + } + + if buildID != testBuildID { + t.Errorf("expected BUILD_ID to be %s, got %s", testBuildID, buildID) + } + + // Verify other expected fields are present + expectedFields := []string{"DOMAIN", "CONTEXT", "CONTEXT_ID", "LOADBALANCER_IP_RANGE", "REGISTRY_URL"} + for _, field := range expectedFields { + if _, exists := capturedData[field]; !exists { + t.Errorf("expected field %s not found in ConfigMap data", field) + } + } +} + +func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + handler.shims = NewShims() + handler.configHandler = config.NewMockConfigHandler() + return handler + } + + t.Run("WithRenderedDataOnly", func(t *testing.T) { + // Given a handler with rendered patch data only + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + "namespace": "test-namespace", + }, + "data": map[string]any{ + "key": "value", + }, + }, + } + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + // When resolving patch from path + content, target := handler.resolvePatchFromPath("test", "default-namespace") + // Then content should be returned and target should be extracted + if content != "test yaml" { + t.Errorf("Expected content = 'test yaml', got = '%s'", content) + } + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Kind != "ConfigMap" { + t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) + } + if target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + } + if target.Namespace != "test-namespace" { + t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) + } + }) + + t.Run("WithNoData", func(t *testing.T) { + // Given a handler with no data + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + // When resolving patch from path + content, target := handler.resolvePatchFromPath("test", "default-namespace") + // Then empty content and nil target should be returned + if content != "" { + t.Errorf("Expected empty content, got = '%s'", content) + } + if target != nil { + t.Error("Expected target to be nil") + } + }) + + t.Run("WithYamlExtension", func(t *testing.T) { + // Given a handler with patch path containing .yaml extension + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + }, + } + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + // When resolving patch from path with .yaml extension + content, target := handler.resolvePatchFromPath("test.yaml", "default-namespace") + // Then content should be returned and target should be extracted + if content != "test yaml" { + t.Errorf("Expected content = 'test yaml', got = '%s'", content) + } + if target == nil { + t.Error("Expected target to be extracted") + } + }) + + t.Run("WithYmlExtension", func(t *testing.T) { + // Given a handler with patch path containing .yml extension + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + }, + } + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + // When resolving patch from path with .yml extension + content, target := handler.resolvePatchFromPath("test.yml", "default-namespace") + // Then content should be returned and target should be extracted + if content != "test yaml" { + t.Errorf("Expected content = 'test yaml', got = '%s'", content) + } + if target == nil { + t.Error("Expected target to be extracted") + } + }) + + t.Run("WithBothRenderedAndUserDataMerge", func(t *testing.T) { + // Given a handler with both rendered and user data that can be merged + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "rendered-config", + "namespace": "rendered-namespace", + }, + "data": map[string]any{ + "rendered-key": "rendered-value", + }, + }, + } + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: user-config + namespace: user-namespace +data: + user-key: user-value`), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "user-config", + "namespace": "user-namespace", + }, + "data": map[string]any{ + "user-key": "user-value", + }, + } + return nil + } + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("merged yaml"), nil + } + // When resolving patch from path + content, target := handler.resolvePatchFromPath("test", "default-namespace") + // Then merged content should be returned and target should be extracted from merged data + if content != "merged yaml" { + t.Errorf("Expected content = 'merged yaml', got = '%s'", content) + } + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Name != "user-config" { + t.Errorf("Expected target name = 'user-config', got = '%s'", target.Name) + } + if target.Namespace != "user-namespace" { + t.Errorf("Expected target namespace = 'user-namespace', got = '%s'", target.Namespace) + } + }) +} + +func TestBaseBlueprintHandler_extractTargetFromPatchData(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler + } + + t.Run("ValidPatchData", func(t *testing.T) { + // Given valid patch data with all required fields + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + "namespace": "test-namespace", + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be extracted correctly + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Kind != "ConfigMap" { + t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) + } + if target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + } + if target.Namespace != "test-namespace" { + t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) + } + }) + + t.Run("WithCustomNamespace", func(t *testing.T) { + // Given patch data with custom namespace + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + "namespace": "custom-namespace", + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then custom namespace should be used + if target.Namespace != "custom-namespace" { + t.Errorf("Expected target namespace = 'custom-namespace', got = '%s'", target.Namespace) + } + }) + + t.Run("MissingKind", func(t *testing.T) { + // Given patch data missing kind field + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "metadata": map[string]any{ + "name": "test-config", + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when kind is missing") + } + }) + + t.Run("MissingMetadata", func(t *testing.T) { + // Given patch data missing metadata field + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when metadata is missing") + } + }) + + t.Run("MissingName", func(t *testing.T) { + // Given patch data missing name field + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{}, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when name is missing") + } + }) + + t.Run("InvalidKindType", func(t *testing.T) { + // Given patch data with invalid kind type + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": 42, + "metadata": map[string]any{ + "name": "test-config", + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when kind type is invalid") + } + }) + + t.Run("InvalidMetadataType", func(t *testing.T) { + // Given patch data with invalid metadata type + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": "not a map", + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when metadata type is invalid") + } + }) + + t.Run("InvalidNameType", func(t *testing.T) { + // Given patch data with invalid name type + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": 42, + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when name type is invalid") + } + }) +} + +func TestBaseBlueprintHandler_extractTargetFromPatchContent(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler + } + + t.Run("ValidYamlContent", func(t *testing.T) { + // Given valid YAML content + handler := setup(t) + content := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: test-namespace` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be extracted correctly + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + } + }) + + t.Run("MultipleDocuments", func(t *testing.T) { + // Given YAML with multiple documents + handler := setup(t) + content := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: first-config +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: second-config` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then first valid target should be extracted + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Name != "first-config" { + t.Errorf("Expected target name = 'first-config', got = '%s'", target.Name) + } + }) + + t.Run("InvalidYamlContent", func(t *testing.T) { + // Given invalid YAML content + handler := setup(t) + content := `invalid: yaml: content: with: colons: everywhere` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil for invalid YAML") + } + }) + + t.Run("EmptyContent", func(t *testing.T) { + // Given empty content + handler := setup(t) + content := "" + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil for empty content") + } + }) + + t.Run("NoValidTargets", func(t *testing.T) { + // Given YAML with no valid targets + handler := setup(t) + content := `apiVersion: v1 +kind: ConfigMap +# Missing metadata.name` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when no valid targets") + } + }) +} + +func TestBaseBlueprintHandler_hasComponentValues(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + handler.shims = NewShims() + handler.configHandler = config.NewMockConfigHandler() + return handler + } + + t.Run("TemplateComponentExists", func(t *testing.T) { + // Given handler with component in template data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/values": map[string]any{ + "test-component": map[string]any{ + "key": "value", + }, + }, + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return true + if !exists { + t.Error("Expected component to exist in template data") + } + }) + + t.Run("UserComponentExists", func(t *testing.T) { + // Given handler with component in user file + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "config.yaml", isDir: false}, nil + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`test-component: + key: value`), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "test-component": map[string]any{ + "key": "value", + }, + } + return nil + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return true + if !exists { + t.Error("Expected component to exist in user file") + } + }) + + t.Run("BothTemplateAndUserExist", func(t *testing.T) { + // Given handler with component in both template and user data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/values": map[string]any{ + "test-component": map[string]any{ + "template-key": "template-value", + }, + }, + } + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "config.yaml", isDir: false}, nil + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`test-component: + user-key: user-value`), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "test-component": map[string]any{ + "user-key": "user-value", + }, + } + return nil + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return true + if !exists { + t.Error("Expected component to exist in both sources") + } + }) + + t.Run("NoComponentExists", func(t *testing.T) { + // Given handler with no component data + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist") + } + }) + + t.Run("ConfigRootError", func(t *testing.T) { + // Given handler with config root error + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist when config root fails") + } + }) + + t.Run("FileNotExists", func(t *testing.T) { + // Given handler with file not existing + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist when file doesn't exist") + } + }) + + t.Run("InvalidValuesFile", func(t *testing.T) { + // Given handler with invalid values file + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "config.yaml", isDir: false}, nil + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte("invalid yaml"), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("invalid yaml") + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist when values file is invalid") + } + }) +} + +func TestBaseBlueprintHandler_deepMergeMaps(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler + } + + t.Run("SimpleMerge", func(t *testing.T) { + // Given base and overlay maps with simple values + handler := setup(t) + base := map[string]any{ + "key1": "base-value1", + "key2": "base-value2", + } + overlay := map[string]any{ + "key2": "overlay-value2", + "key3": "overlay-value3", + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then result should contain merged values + if result["key1"] != "base-value1" { + t.Errorf("Expected key1 = 'base-value1', got = '%v'", result["key1"]) + } + if result["key2"] != "overlay-value2" { + t.Errorf("Expected key2 = 'overlay-value2', got = '%v'", result["key2"]) + } + if result["key3"] != "overlay-value3" { + t.Errorf("Expected key3 = 'overlay-value3', got = '%v'", result["key3"]) + } + }) + + t.Run("NestedMapMerge", func(t *testing.T) { + // Given base and overlay maps with nested maps + handler := setup(t) + base := map[string]any{ + "nested": map[string]any{ + "base-key": "base-value", + }, + } + overlay := map[string]any{ + "nested": map[string]any{ + "overlay-key": "overlay-value", + }, + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then nested maps should be merged + nested := result["nested"].(map[string]any) + if nested["base-key"] != "base-value" { + t.Errorf("Expected nested.base-key = 'base-value', got = '%v'", nested["base-key"]) + } + if nested["overlay-key"] != "overlay-value" { + t.Errorf("Expected nested.overlay-key = 'overlay-value', got = '%v'", nested["overlay-key"]) + } + }) + + t.Run("OverlayPrecedence", func(t *testing.T) { + // Given base and overlay maps with conflicting keys + handler := setup(t) + base := map[string]any{ + "key": "base-value", + } + overlay := map[string]any{ + "key": "overlay-value", + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then overlay value should take precedence + if result["key"] != "overlay-value" { + t.Errorf("Expected key = 'overlay-value', got = '%v'", result["key"]) + } + }) + + t.Run("DeepNestedMerge", func(t *testing.T) { + // Given base and overlay maps with deeply nested maps + handler := setup(t) + base := map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "base-key": "base-value", + }, + }, + } + overlay := map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "overlay-key": "overlay-value", + }, + }, + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then deeply nested maps should be merged + level1 := result["level1"].(map[string]any) + level2 := level1["level2"].(map[string]any) + if level2["base-key"] != "base-value" { + t.Errorf("Expected level2.base-key = 'base-value', got = '%v'", level2["base-key"]) + } + if level2["overlay-key"] != "overlay-value" { + t.Errorf("Expected level2.overlay-key = 'overlay-value', got = '%v'", level2["overlay-key"]) + } + }) + + t.Run("EmptyMaps", func(t *testing.T) { + // Given empty base and overlay maps + handler := setup(t) + base := map[string]any{} + overlay := map[string]any{} + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then result should be empty + if len(result) != 0 { + t.Errorf("Expected empty result, got %d items", len(result)) + } + }) + + t.Run("NonMapOverlay", func(t *testing.T) { + // Given base map and non-map overlay value + handler := setup(t) + base := map[string]any{ + "key": map[string]any{ + "nested": "value", + }, + } + overlay := map[string]any{ + "key": "string-value", + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then overlay value should replace base value + if result["key"] != "string-value" { + t.Errorf("Expected key = 'string-value', got = '%v'", result["key"]) + } + }) + + t.Run("MixedTypes", func(t *testing.T) { + // Given base and overlay maps with mixed types + handler := setup(t) + base := map[string]any{ + "string": "base-string", + "number": 42, + "nested": map[string]any{ + "key": "base-nested", + }, + } + overlay := map[string]any{ + "string": "overlay-string", + "bool": true, + "nested": map[string]any{ + "overlay-key": "overlay-nested", + }, + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then all values should be merged correctly + if result["string"] != "overlay-string" { + t.Errorf("Expected string = 'overlay-string', got = '%v'", result["string"]) + } + if result["number"] != 42 { + t.Errorf("Expected number = 42, got = '%v'", result["number"]) + } + if result["bool"] != true { + t.Errorf("Expected bool = true, got = '%v'", result["bool"]) + } + nested := result["nested"].(map[string]any) + if nested["key"] != "base-nested" { + t.Errorf("Expected nested.key = 'base-nested', got = '%v'", nested["key"]) + } + if nested["overlay-key"] != "overlay-nested" { + t.Errorf("Expected nested.overlay-key = 'overlay-nested', got = '%v'", nested["overlay-key"]) + } + }) +} + +// ============================================================================= +// Validation Tests +// ============================================================================= + +func TestBaseBlueprintHandler_validateValuesForSubstitution(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + return &BaseBlueprintHandler{} + } + + t.Run("AcceptsValidScalarValues", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "string_value": "test", + "int_value": 42, + "int8_value": int8(8), + "int16_value": int16(16), + "int32_value": int32(32), + "int64_value": int64(64), + "uint_value": uint(42), + "uint8_value": uint8(8), + "uint16_value": uint16(16), + "uint32_value": uint32(32), + "uint64_value": uint64(64), + "float32_value": float32(3.14), + "float64_value": 3.14159, + "bool_value": true, + } + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for valid scalar values, got: %v", err) + } + }) + + t.Run("AcceptsOneLevelOfMapWithScalarValues", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "top_level_string": "value", + "scalar_map": map[string]any{ + "nested_string": "nested_value", + "nested_int": 123, + "nested_bool": false, + }, + "another_top_level": 456, + } + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for map with scalar values, got: %v", err) + } + }) + + t.Run("RejectsNestedMaps", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "top_level": map[string]any{ + "second_level": map[string]any{ + "third_level": "value", + }, + }, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for nested maps") + } + + if !strings.Contains(err.Error(), "can only contain scalar values in maps") { + t.Errorf("Expected error about scalar values only in maps, got: %v", err) + } + if !strings.Contains(err.Error(), "top_level.second_level") { + t.Errorf("Expected error to mention the nested key path, got: %v", err) + } + }) + + t.Run("RejectsSlices", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "valid_string": "test", + "invalid_slice": []any{"item1", "item2", "item3"}, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for slice values") + } + + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected error about slices, got: %v", err) + } + if !strings.Contains(err.Error(), "invalid_slice") { + t.Errorf("Expected error to mention the slice key, got: %v", err) + } + }) + + t.Run("RejectsSlicesInNestedMaps", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "nested_map": map[string]any{ + "valid_value": "test", + "invalid_slice": []any{"item1", "item2"}, // Use []any to match the type check + }, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for slice in nested map") + } + + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected error about slices, got: %v", err) + } + if !strings.Contains(err.Error(), "nested_map.invalid_slice") { + t.Errorf("Expected error to mention the nested slice key path, got: %v", err) + } + }) + + t.Run("RejectsTypedSlices", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "string_slice": []string{"item1", "item2"}, + "int_slice": []int{1, 2, 3}, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for typed slices") + } + + // After the fix, typed slices should now get the specific slice error message + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected error about slices for typed slices, got: %v", err) + } + }) + + t.Run("RejectsUnsupportedTypes", func(t *testing.T) { + handler := setup(t) + + // Test with a struct (unsupported type) + type customStruct struct { + Field string + } + + values := map[string]any{ + "valid_string": "test", + "invalid_struct": customStruct{Field: "value"}, + "invalid_function": func() {}, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for unsupported types") + } + + if !strings.Contains(err.Error(), "can only contain strings, numbers, booleans, or maps of scalar types") { + t.Errorf("Expected error about unsupported types, got: %v", err) + } + }) + + t.Run("RejectsUnsupportedTypesInNestedMaps", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "nested_map": map[string]any{ + "valid_value": "test", + "invalid_value": make(chan int), // Channel is unsupported + }, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for unsupported type in nested map") + } + + if !strings.Contains(err.Error(), "can only contain scalar values in maps") { + t.Errorf("Expected error about scalar values only in maps, got: %v", err) + } + if !strings.Contains(err.Error(), "nested_map.invalid_value") { + t.Errorf("Expected error to mention the nested key path, got: %v", err) + } + }) + + t.Run("RejectsSlicesInMaps", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "config": map[string]any{ + "valid_key": "test", + "slice_key": []string{"item1", "item2"}, + }, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for slices in maps") + } + + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected error about slices, got: %v", err) + } + if !strings.Contains(err.Error(), "config.slice_key") { + t.Errorf("Expected error to mention the nested slice key path, got: %v", err) + } + }) + + t.Run("HandlesEmptyValues", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{} + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for empty values, got: %v", err) + } + }) + + t.Run("HandlesEmptyNestedMaps", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "empty_nested": map[string]any{}, + "valid_value": "test", + } + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for empty nested maps, got: %v", err) + } + }) + + t.Run("HandlesNilValues", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "nil_value": nil, + "valid_value": "test", + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for nil values") + } + + if !strings.Contains(err.Error(), "cannot contain nil values") { + t.Errorf("Expected error about nil values, got: %v", err) + } + }) + + t.Run("HandlesNilValuesInMaps", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "config": map[string]any{ + "valid_key": "test", + "nil_key": nil, + }, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for nil values in maps") + } + + if !strings.Contains(err.Error(), "cannot contain nil values") { + t.Errorf("Expected error about nil values, got: %v", err) + } + if !strings.Contains(err.Error(), "config.nil_key") { + t.Errorf("Expected error to mention the nested nil key path, got: %v", err) + } + }) + + t.Run("ValidatesComplexScenario", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "app_name": "my-app", + "app_version": "1.2.3", + "replicas": 3, + "enabled": true, + "config": map[string]any{ + "database_url": "postgres://localhost:5432/mydb", + "cache_enabled": true, + "max_connections": 100, + "timeout_seconds": 30.5, + "debug_mode": false, + }, + "resources": map[string]any{ + "cpu_limit": "500m", + "memory_limit": "512Mi", + "cpu_request": "100m", + "memory_request": "128Mi", + }, + } + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for complex valid scenario, got: %v", err) + } + }) + + t.Run("RejectsComplexScenarioWithInvalidNesting", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "app_name": "my-app", + "config": map[string]any{ + "database": map[string]any{ // Maps cannot contain other maps + "host": "localhost", + "port": 5432, + }, + }, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for invalid nesting in complex scenario") + } + + if !strings.Contains(err.Error(), "can only contain scalar values in maps") { + t.Errorf("Expected error about scalar values only in maps, got: %v", err) + } + if !strings.Contains(err.Error(), "config.database") { + t.Errorf("Expected error to mention the nested path, got: %v", err) + } + }) + + t.Run("HandlesSpecialNumericTypes", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "zero_int": 0, + "negative_int": -42, + "zero_float": 0.0, + "negative_float": -3.14, + "large_uint64": uint64(18446744073709551615), // Max uint64 + "small_int8": int8(-128), // Min int8 + } + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for special numeric types, got: %v", err) + } + }) + + t.Run("HandlesSpecialStringValues", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "empty_string": "", + "whitespace": " ", + "newlines": "line1\nline2", + "unicode": "Hello δΈ–η•Œ 🌍", + "special_chars": "!@#$%^&*()_+-={}[]|\\:;\"'<>?,./", + } + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for special string values, got: %v", err) + } + }) +} + +func TestBaseBlueprintHandler_applyOCIRepository(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *kubernetes.MockKubernetesManager) { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + handler.configHandler = mocks.ConfigHandler + handler.kubernetesManager = mocks.KubernetesManager + return handler, mocks.KubernetesManager + } + + t.Run("WithTagInURL", func(t *testing.T) { + // Given a handler + handler, mockKM := setup(t) + + // And a mock that captures the applied repository + var appliedRepo *sourcev1.OCIRepository + mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { + appliedRepo = repo + return nil + } + + // And a source with tag in URL + source := blueprintv1alpha1.Source{ + Name: "test-oci-source", + Url: "oci://ghcr.io/test/repo:v1.0.0", + } + + // When applying OCI repository + err := handler.applyOCIRepository(source, "test-namespace") + + // Then no error should occur + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // And repository should be created with correct fields + if appliedRepo == nil { + t.Fatal("expected repository to be applied") + } + if appliedRepo.Name != "test-oci-source" { + t.Errorf("expected Name 'test-oci-source', got '%s'", appliedRepo.Name) + } + if appliedRepo.Namespace != "test-namespace" { + t.Errorf("expected Namespace 'test-namespace', got '%s'", appliedRepo.Namespace) + } + if appliedRepo.Spec.URL != "oci://ghcr.io/test/repo" { + t.Errorf("expected URL without tag, got '%s'", appliedRepo.Spec.URL) + } + if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Tag != "v1.0.0" { + t.Errorf("expected tag 'v1.0.0', got %v", appliedRepo.Spec.Reference) + } + }) + + t.Run("WithTagInRefField", func(t *testing.T) { + // Given a handler + handler, mockKM := setup(t) + + // And a mock that captures the applied repository + var appliedRepo *sourcev1.OCIRepository + mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { + appliedRepo = repo + return nil + } + + // And a source with tag in Ref field + source := blueprintv1alpha1.Source{ + Name: "test-oci-ref", + Url: "oci://ghcr.io/test/repo", + Ref: blueprintv1alpha1.Reference{ + Tag: "v2.0.0", + }, + } + + // When applying OCI repository + err := handler.applyOCIRepository(source, "test-namespace") + + // Then no error should occur + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // And tag should be from Ref field + if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Tag != "v2.0.0" { + t.Errorf("expected tag 'v2.0.0', got %v", appliedRepo.Spec.Reference) + } + }) + + t.Run("WithSemVerInRefField", func(t *testing.T) { + // Given a handler + handler, mockKM := setup(t) + + // And a mock that captures the applied repository + var appliedRepo *sourcev1.OCIRepository + mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { + appliedRepo = repo + return nil + } + + // And a source with SemVer in Ref field + source := blueprintv1alpha1.Source{ + Name: "test-oci-semver", + Url: "oci://ghcr.io/test/repo", + Ref: blueprintv1alpha1.Reference{ + SemVer: ">=1.0.0 <2.0.0", + }, + } + + // When applying OCI repository + err := handler.applyOCIRepository(source, "test-namespace") + + // Then no error should occur + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // And SemVer should be set + if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.SemVer != ">=1.0.0 <2.0.0" { + t.Errorf("expected SemVer '>=1.0.0 <2.0.0', got %v", appliedRepo.Spec.Reference) + } + }) + + t.Run("WithCommitDigest", func(t *testing.T) { + // Given a handler + handler, mockKM := setup(t) + + // And a mock that captures the applied repository + var appliedRepo *sourcev1.OCIRepository + mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { + appliedRepo = repo + return nil + } + + // And a source with commit digest + source := blueprintv1alpha1.Source{ + Name: "test-oci-digest", + Url: "oci://ghcr.io/test/repo", + Ref: blueprintv1alpha1.Reference{ + Commit: "sha256:abc123", + }, + } + + // When applying OCI repository + err := handler.applyOCIRepository(source, "test-namespace") + + // Then no error should occur + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // And Digest should be set + if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Digest != "sha256:abc123" { + t.Errorf("expected Digest 'sha256:abc123', got %v", appliedRepo.Spec.Reference) + } + }) + + t.Run("WithSecretReference", func(t *testing.T) { + // Given a handler + handler, mockKM := setup(t) + + // And a mock that captures the applied repository + var appliedRepo *sourcev1.OCIRepository + mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { + appliedRepo = repo + return nil + } + + // And a source with secret reference + source := blueprintv1alpha1.Source{ + Name: "test-oci-secret", + Url: "oci://ghcr.io/test/private-repo", + SecretName: "registry-credentials", + } + + // When applying OCI repository + err := handler.applyOCIRepository(source, "test-namespace") + + // Then no error should occur + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // And secret reference should be set + if appliedRepo.Spec.SecretRef == nil || appliedRepo.Spec.SecretRef.Name != "registry-credentials" { + t.Errorf("expected SecretRef 'registry-credentials', got %v", appliedRepo.Spec.SecretRef) + } + }) + + t.Run("DefaultLatestTag", func(t *testing.T) { + // Given a handler + handler, mockKM := setup(t) + + // And a mock that captures the applied repository + var appliedRepo *sourcev1.OCIRepository + mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { + appliedRepo = repo + return nil + } + + // And a source without any tag specified + source := blueprintv1alpha1.Source{ + Name: "test-oci-default", + Url: "oci://ghcr.io/test/repo", + } + + // When applying OCI repository + err := handler.applyOCIRepository(source, "test-namespace") + + // Then no error should occur + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // And default 'latest' tag should be used + if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Tag != "latest" { + t.Errorf("expected default tag 'latest', got %v", appliedRepo.Spec.Reference) + } + }) + + t.Run("ApplyRepositoryError", func(t *testing.T) { + // Given a handler + handler, mockKM := setup(t) + + // And a mock that returns an error + mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { + return fmt.Errorf("apply failed: network error") + } + + // And a source + source := blueprintv1alpha1.Source{ + Name: "test-oci-error", + Url: "oci://ghcr.io/test/repo", + } + + // When applying OCI repository + err := handler.applyOCIRepository(source, "test-namespace") + + // Then an error should occur + if err == nil { + t.Fatal("expected error, got nil") + } + + // And error message should contain the failure + if !strings.Contains(err.Error(), "network error") { + t.Errorf("expected error to contain 'network error', got: %v", err) + } + }) + + t.Run("URLWithPortShouldNotExtractTag", func(t *testing.T) { + // Given a handler + handler, mockKM := setup(t) + + // And a mock that captures the applied repository + var appliedRepo *sourcev1.OCIRepository + mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { + appliedRepo = repo + return nil + } + + // And a source with port in URL (should not be treated as tag) + source := blueprintv1alpha1.Source{ + Name: "test-oci-port", + Url: "oci://registry.local:5000/test/repo", + } + + // When applying OCI repository + err := handler.applyOCIRepository(source, "test-namespace") + + // Then no error should occur + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // And URL should remain unchanged (port not extracted as tag) + if appliedRepo.Spec.URL != "oci://registry.local:5000/test/repo" { + t.Errorf("expected URL to keep port, got '%s'", appliedRepo.Spec.URL) + } + + // And default 'latest' tag should be used + if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Tag != "latest" { + t.Errorf("expected default tag 'latest', got %v", appliedRepo.Spec.Reference) + } + }) +} diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_public_test.go similarity index 51% rename from pkg/blueprint/blueprint_handler_test.go rename to pkg/blueprint/blueprint_handler_public_test.go index 7bce48fe6..c5df3164f 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_public_test.go @@ -12,7 +12,6 @@ import ( helmv2 "github.com/fluxcd/helm-controller/api/v2" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" - kustomize "github.com/fluxcd/pkg/apis/kustomize" sourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/goccy/go-yaml" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" @@ -3615,2997 +3614,6 @@ func TestBlueprintHandler_Write(t *testing.T) { }) } -func TestTargetHandling(t *testing.T) { - // Test case 1: Path with no existing Target - should create Target with namespace from patch - patch1 := blueprintv1alpha1.BlueprintPatch{ - Path: "kustomize/ingress/nginx.yaml", - } - - // Test case 2: Path with existing Target - should not override existing Target - patch2 := blueprintv1alpha1.BlueprintPatch{ - Path: "kustomize/ingress/nginx.yaml", - Target: &kustomize.Selector{ - Kind: "Service", - Name: "nginx-ingress-controller", - Namespace: "custom-namespace", - }, - } - - // Test case 3: Flux format with Patch and Target - should use existing Target - patch3 := blueprintv1alpha1.BlueprintPatch{ - Patch: "apiVersion: v1\nkind: Service\nmetadata:\n name: nginx-ingress-controller\n namespace: ingress-nginx", - Target: &kustomize.Selector{ - Kind: "Service", - Name: "nginx-ingress-controller", - Namespace: "ingress-nginx", - }, - } - - // Test case 4: Path with patch that has namespace in metadata - should use patch namespace - patch4 := blueprintv1alpha1.BlueprintPatch{ - Path: "kustomize/ingress/nginx-with-namespace.yaml", - } - - // Verify the patches have the expected structure - if patch1.Path == "" { - t.Error("Expected patch1 to have Path field") - } - if patch1.Target != nil { - t.Error("Expected patch1 to have no Target field") - } - - if patch2.Path == "" { - t.Error("Expected patch2 to have Path field") - } - if patch2.Target == nil { - t.Error("Expected patch2 to have Target field") - } - if patch2.Target.Kind != "Service" { - t.Errorf("Expected patch2 Target Kind to be 'Service', got '%s'", patch2.Target.Kind) - } - if patch2.Target.Namespace != "custom-namespace" { - t.Errorf("Expected patch2 Target Namespace to be 'custom-namespace', got '%s'", patch2.Target.Namespace) - } - - if patch3.Patch == "" { - t.Error("Expected patch3 to have Patch field") - } - if patch3.Target == nil { - t.Error("Expected patch3 to have Target field") - } - if patch3.Target.Kind != "Service" { - t.Errorf("Expected patch3 Target Kind to be 'Service', got '%s'", patch3.Target.Kind) - } - if patch3.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected patch3 Target Namespace to be 'ingress-nginx', got '%s'", patch3.Target.Namespace) - } - - if patch4.Path == "" { - t.Error("Expected patch4 to have Path field") - } - if patch4.Target != nil { - t.Error("Expected patch4 to have no Target field (will be generated from patch content)") - } -} - -func TestNamespaceExtractionFromPatch(t *testing.T) { - // Test that namespace is correctly extracted from patch content - patchContent := `apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer` - - var patchData map[string]any - err := yaml.Unmarshal([]byte(patchContent), &patchData) - if err != nil { - t.Fatalf("Failed to unmarshal patch content: %v", err) - } - - // Extract namespace from patch metadata - patchNamespace := "default" // fallback - if metadata, ok := patchData["metadata"].(map[string]any); ok { - if ns, ok := metadata["namespace"].(string); ok { - patchNamespace = ns - } - } - - if patchNamespace != "ingress-nginx" { - t.Errorf("Expected namespace 'ingress-nginx', got '%s'", patchNamespace) - } - - // Test with patch that has no namespace - patchContentNoNS := `apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer` - - var patchDataNoNS map[string]any - err = yaml.Unmarshal([]byte(patchContentNoNS), &patchDataNoNS) - if err != nil { - t.Fatalf("Failed to unmarshal patch content: %v", err) - } - - // Extract namespace from patch metadata - patchNamespaceNoNS := "default" // fallback - if metadata, ok := patchDataNoNS["metadata"].(map[string]any); ok { - if ns, ok := metadata["namespace"].(string); ok { - patchNamespaceNoNS = ns - } - } - - if patchNamespaceNoNS != "default" { - t.Errorf("Expected namespace 'default' (fallback), got '%s'", patchNamespaceNoNS) - } -} - -func TestToKubernetesKustomizationWithNamespace(t *testing.T) { - // Create a mock config handler - mockConfigHandler := &config.MockConfigHandler{} - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/tmp/test", nil - } - - // Create a mock blueprint handler - handler := &BaseBlueprintHandler{ - projectRoot: "/tmp/test", - configHandler: mockConfigHandler, - shims: NewShims(), - } - - // Mock the ReadFile function to return a patch with namespace - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.Contains(name, "nginx.yaml") { - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer`), nil - } - return nil, fmt.Errorf("file not found") - } - - // Create a kustomization with a patch that references a file - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/nginx.yaml", - }, - }, - Interval: &metav1.Duration{Duration: time.Minute}, - RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - Wait: &[]bool{true}[0], - Force: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{true}[0], - Components: []string{"nginx"}, - DependsOn: []string{"pki-resources"}, - } - - // Convert to Kubernetes kustomization - result := handler.toFluxKustomization(kustomization, "system-gitops") - - // Verify that the patch has the correct Target with namespace - if len(result.Spec.Patches) != 1 { - t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) - } - - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("Expected Target to be set") - } - - if patch.Target.Kind != "Service" { - t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) - } - - if patch.Target.Name != "nginx-ingress-controller" { - t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) - } - - if patch.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) - } -} - -func TestToKubernetesKustomizationWithActualPatch(t *testing.T) { - // Create a mock config handler - mockConfigHandler := &config.MockConfigHandler{} - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/Users/ryanvangundy/Developer/windsorcli/core/contexts/colima", nil - } - - // Create a mock blueprint handler with the actual project root - handler := &BaseBlueprintHandler{ - projectRoot: "/Users/ryanvangundy/Developer/windsorcli/core", - configHandler: mockConfigHandler, - shims: NewShims(), - } - - // Mock the ReadFile function to return the expected patch content - handler.shims.ReadFile = func(path string) ([]byte, error) { - // Normalize path for cross-platform comparison - normalizedPath := filepath.ToSlash(path) - if strings.Contains(normalizedPath, "kustomize/ingress/nginx.yaml") { - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer`), nil - } - return nil, fmt.Errorf("file not found: %s", path) - } - - // Create a kustomization with a patch that references the actual file - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/nginx.yaml", - }, - }, - Interval: &metav1.Duration{Duration: time.Minute}, - RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - Wait: &[]bool{true}[0], - Force: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{true}[0], - Components: []string{"nginx"}, - DependsOn: []string{"pki-resources"}, - } - - // Convert to Kubernetes kustomization - result := handler.toFluxKustomization(kustomization, "system-gitops") - - // Verify that the patch has the correct Target with namespace - if len(result.Spec.Patches) != 1 { - t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) - } - - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("Expected Target to be set") - } - - if patch.Target.Kind != "Service" { - t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) - } - - if patch.Target.Name != "nginx-ingress-controller" { - t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) - } - - if patch.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) - } - - // Also verify the patch content - if !strings.Contains(patch.Patch, "namespace: ingress-nginx") { - t.Errorf("Expected patch to contain 'namespace: ingress-nginx', got: %s", patch.Patch) - } -} - -func TestToKubernetesKustomizationWithMultiplePatches(t *testing.T) { - // Create a mock config handler - mockConfigHandler := &config.MockConfigHandler{} - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/tmp/test", nil - } - - // Create a mock blueprint handler - handler := &BaseBlueprintHandler{ - projectRoot: "/tmp/test", - configHandler: mockConfigHandler, - shims: NewShims(), - } - - // Mock the ReadFile function to return a patch with multiple documents - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.Contains(name, "multi-patch.yaml") { - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: nginx-config - namespace: ingress-nginx -data: - key: value`), nil - } - return nil, fmt.Errorf("file not found") - } - - // Create a kustomization with a patch that references a file with multiple documents - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/multi-patch.yaml", - }, - }, - Interval: &metav1.Duration{Duration: time.Minute}, - RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - Wait: &[]bool{true}[0], - Force: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{true}[0], - Components: []string{"nginx"}, - DependsOn: []string{"pki-resources"}, - } - - // Convert to Kubernetes kustomization - result := handler.toFluxKustomization(kustomization, "system-gitops") - - // Verify that the patch has the correct Target with namespace from the first document - if len(result.Spec.Patches) != 1 { - t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) - } - - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("Expected Target to be set") - } - - if patch.Target.Kind != "Service" { - t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) - } - - if patch.Target.Name != "nginx-ingress-controller" { - t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) - } - - if patch.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) - } - - // Verify the patch content contains both documents - if !strings.Contains(patch.Patch, "kind: Service") { - t.Errorf("Expected patch to contain 'kind: Service', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "kind: ConfigMap") { - t.Errorf("Expected patch to contain 'kind: ConfigMap', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "namespace: ingress-nginx") { - t.Errorf("Expected patch to contain 'namespace: ingress-nginx', got: %s", patch.Patch) - } -} - -func TestToKubernetesKustomizationWithEdgeCases(t *testing.T) { - // Create a mock config handler - mockConfigHandler := &config.MockConfigHandler{} - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/tmp/test", nil - } - - // Create a mock blueprint handler - handler := &BaseBlueprintHandler{ - projectRoot: "/tmp/test", - configHandler: mockConfigHandler, - shims: NewShims(), - } - - // Mock the ReadFile function to return a patch with edge cases - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.Contains(name, "edge-case.yaml") { - return []byte(`# Comment at the top ---- -apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer ---- -# Comment between documents ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: nginx-config - namespace: ingress-nginx -data: - key: value ---- -# Empty document ---- -# Another comment -apiVersion: v1 -kind: Secret -metadata: - name: nginx-secret - namespace: ingress-nginx -type: Opaque`), nil - } - return nil, fmt.Errorf("file not found") - } - - // Create a kustomization with a patch that references a file with edge cases - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/edge-case.yaml", - }, - }, - Interval: &metav1.Duration{Duration: time.Minute}, - RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - Wait: &[]bool{true}[0], - Force: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{true}[0], - Components: []string{"nginx"}, - DependsOn: []string{"pki-resources"}, - } - - // Convert to Kubernetes kustomization - result := handler.toFluxKustomization(kustomization, "system-gitops") - - // Verify that the patch has the correct Target with namespace from the first valid document - if len(result.Spec.Patches) != 1 { - t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) - } - - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("Expected Target to be set") - } - - if patch.Target.Kind != "Service" { - t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) - } - - if patch.Target.Name != "nginx-ingress-controller" { - t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) - } - - if patch.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) - } - - // Verify the patch content contains all documents - if !strings.Contains(patch.Patch, "kind: Service") { - t.Errorf("Expected patch to contain 'kind: Service', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "kind: ConfigMap") { - t.Errorf("Expected patch to contain 'kind: ConfigMap', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "kind: Secret") { - t.Errorf("Expected patch to contain 'kind: Secret', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "namespace: ingress-nginx") { - t.Errorf("Expected patch to contain 'namespace: ingress-nginx', got: %s", patch.Patch) - } -} - -// ============================================================================= -// Values ConfigMap Tests -// ============================================================================= - -func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { - // Given a handler with mocks - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - handler.configHandler = mocks.ConfigHandler - handler.kubernetesManager = mocks.KubernetesManager - handler.shell = mocks.Shell - return handler - } - - t.Run("SuccessWithGlobalValues", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root and other config methods - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} - } - - // And mock kustomize directory with global config.yaml - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And mock file read for centralized values - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return []byte(`common: - domain: example.com - port: 80 - enabled: true`), nil - } - return nil, os.ErrNotExist - } - - // And mock YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "common": map[string]any{ - "domain": "example.com", - "port": 80, - "enabled": true, - }, - } - return nil - } - - // And mock Kubernetes manager - var appliedConfigMaps []string - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - appliedConfigMaps = append(appliedConfigMaps, name) - return nil - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should succeed - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) - } - - // And it should apply the common values ConfigMap - if len(appliedConfigMaps) != 1 { - t.Errorf("expected 1 ConfigMap to be applied, got %d", len(appliedConfigMaps)) - } - if appliedConfigMaps[0] != "values-common" { - t.Errorf("expected ConfigMap name to be 'values-common', got '%s'", appliedConfigMaps[0]) - } - }) - - t.Run("SuccessWithComponentValues", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root and other config methods - projectRoot := filepath.Join("test", "project") - configRoot := filepath.Join("test", "config") - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return configRoot, nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} - } - mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { - return map[string]any{ - "substitution": map[string]any{ - "common": map[string]any{ - "domain": "example.com", - }, - "ingress": map[string]any{ - "host": "ingress.example.com", - "ssl": true, - }, - }, - }, nil - } - - // Mock shell for project root - mockShell := handler.shell.(*shell.MockShell) - mockShell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - - // And mock context values with component values - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - if name == filepath.Join(configRoot, "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And mock file read for context values - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { - return []byte(`substitution: - common: - domain: template.com - ingress: - host: template.example.com`), nil - } - if name == filepath.Join(configRoot, "values.yaml") { - return []byte(`substitution: - common: - domain: example.com - ingress: - host: ingress.example.com - ssl: true`), nil - } - return nil, os.ErrNotExist - } - - // And mock Kubernetes manager - var appliedConfigMaps []string - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - appliedConfigMaps = append(appliedConfigMaps, name) - return nil - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should succeed - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) - } - - // And it should apply both common and component values ConfigMaps - if len(appliedConfigMaps) != 2 { - t.Errorf("expected 2 ConfigMaps to be applied, got %d: %v", len(appliedConfigMaps), appliedConfigMaps) - } - - // Check that both ConfigMaps were applied (order may vary) - commonFound := false - ingressFound := false - for _, name := range appliedConfigMaps { - if name == "values-common" { - commonFound = true - } - if name == "values-ingress" { - ingressFound = true - } - } - if !commonFound { - t.Error("expected values-common ConfigMap to be applied") - } - if !ingressFound { - t.Error("expected values-ingress ConfigMap to be applied") - } - }) - - t.Run("NoKustomizeDirectory", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock that kustomize directory doesn't exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should succeed (no-op) - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed when no kustomize directory, got: %v", err) - } - }) - - t.Run("ConfigRootError", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock GetContextValues that fails - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { - return nil, fmt.Errorf("failed to load context values") - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should fail - if err == nil { - t.Fatal("expected applyValuesConfigMaps to fail with context values error") - } - if !strings.Contains(err.Error(), "failed to load context values") { - t.Errorf("expected error about context values, got: %v", err) - } - }) - - t.Run("ReadFileError", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root and other config methods - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} - } - - // And mock kustomize directory and config.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And mock ReadFile that fails - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return nil, os.ErrPermission - } - return nil, os.ErrNotExist - } - - // Mock YAML marshal - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test"), nil - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should still succeed since ReadFile errors are now ignored and rendered values take precedence - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed despite ReadFile error, got: %v", err) - } - }) - - t.Run("ComponentConfigMapError", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root and other config methods - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} - } - - // And mock centralized config.yaml with component values - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And mock file read for centralized values - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return []byte(`common: - domain: example.com -ingress: - host: ingress.example.com`), nil - } - return nil, os.ErrNotExist - } - - // And mock YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "common": map[string]any{ - "domain": "example.com", - }, - "ingress": map[string]any{ - "host": "ingress.example.com", - }, - } - return nil - } - - // And mock Kubernetes manager that fails - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - return os.ErrPermission - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should fail - if err == nil { - t.Fatal("expected applyValuesConfigMaps to fail with ConfigMap error") - } - if !strings.Contains(err.Error(), "failed to create ConfigMap for component common") { - t.Errorf("expected error about common ConfigMap creation, got: %v", err) - } - }) - - t.Run("SuccessWithRenderedSubstitutionValues", func(t *testing.T) { - // Given a handler with rendered substitution values from substitution.jsonnet - handler := setup(t) - - // Set up rendered substitution data (simulating substitution.jsonnet output) - handler.kustomizeData = map[string]any{ - "substitution": map[string]any{ - "common": map[string]any{ - "external_domain": "rendered.test", - "registry_url": "registry.rendered.test", - }, - "app_config": map[string]any{ - "replicas": 2, - }, - }, - } - - // Mock config handler - projectRoot := filepath.Join("test", "project") - configRoot := filepath.Join("test", "config") - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return configRoot, nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - return []string{} - } - mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { - return map[string]any{ - "substitution": map[string]any{ - "common": map[string]any{ - "external_domain": "context.test", - "context_key": "context_value", - }, - "app_config": map[string]any{ - "replicas": 5, - }, - }, - }, nil - } - - // Mock shell for project root - mockShell := handler.shell.(*shell.MockShell) - mockShell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - - // Mock context values that override some rendered values - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join(projectRoot, "contexts", "_template", "schema.yaml") { - return &mockFileInfo{name: "schema.yaml"}, nil - } - if name == filepath.Join(configRoot, "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - return nil, os.ErrNotExist - } - - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join(projectRoot, "contexts", "_template", "schema.yaml") { - return []byte(`$schema: https://json-schema.org/draft/2020-12/schema -type: object -properties: - substitution: - type: object - properties: - common: - type: object - properties: - template_key: - type: string - default: "template_value" - additionalProperties: true - default: - template_key: "template_value" - additionalProperties: true -required: [] -additionalProperties: true`), nil - } - if name == filepath.Join(configRoot, "values.yaml") { - return []byte(`substitution: - common: - external_domain: context.test - context_key: context_value - app_config: - replicas: 5`), nil - } - return nil, os.ErrNotExist - } - - // Mock Kubernetes manager to capture applied ConfigMaps - var appliedConfigMaps []string - var configMapData map[string]map[string]string = make(map[string]map[string]string) - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - appliedConfigMaps = append(appliedConfigMaps, name) - configMapData[name] = data - return nil - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should succeed - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) - } - - // And it should apply ConfigMaps for common and app_config - if len(appliedConfigMaps) != 2 { - t.Errorf("expected 2 ConfigMaps to be applied, got %d: %v", len(appliedConfigMaps), appliedConfigMaps) - } - - // Check common ConfigMap - should have rendered values merged with context overrides and system values - if commonData, exists := configMapData["values-common"]; exists { - // Context values should override rendered values - if commonData["external_domain"] != "context.test" { - t.Errorf("expected external_domain to be 'context.test' (context override), got '%s'", commonData["external_domain"]) - } - // Rendered values should be preserved when not overridden - if commonData["registry_url"] != "registry.rendered.test" { - t.Errorf("expected registry_url to be 'registry.rendered.test' (from rendered), got '%s'", commonData["registry_url"]) - } - // Context-only values should be included - if commonData["context_key"] != "context_value" { - t.Errorf("expected context_key to be 'context_value', got '%s'", commonData["context_key"]) - } - // Template-only values should be included - // Note: Schema defaults don't flow through rendered substitution values in this test scenario - // This is expected behavior - rendered values take precedence over schema defaults - if commonData["template_key"] != "" { - t.Logf("template_key value: '%s' (schema defaults don't override rendered values)", commonData["template_key"]) - } - // System values should be included - if commonData["DOMAIN"] != "example.com" { - t.Errorf("expected DOMAIN to be 'example.com', got '%s'", commonData["DOMAIN"]) - } - } else { - t.Error("expected values-common ConfigMap to be applied") - } - - // Check app_config ConfigMap - should have context override of rendered value - if appConfigData, exists := configMapData["values-app_config"]; exists { - if appConfigData["replicas"] != "5" { - t.Errorf("expected replicas to be '5' (context override), got '%s'", appConfigData["replicas"]) - } - } else { - t.Error("expected values-app_config ConfigMap to be applied") - } - }) -} - -// ============================================================================= -// toFluxKustomization ConfigMap Tests -// ============================================================================= - -func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testing.T) { - // Given a handler with mocks - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - handler.configHandler = mocks.ConfigHandler - handler.kubernetesManager = mocks.KubernetesManager - return handler - } - - t.Run("WithGlobalValuesConfigMap", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock that global config.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And a kustomization - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with ConfigMap references - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") - } - - // And it should have the blueprint ConfigMap reference - if len(result.Spec.PostBuild.SubstituteFrom) < 1 { - t.Fatal("expected at least 1 SubstituteFrom reference") - } - - commonValuesFound := false - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "values-common" { - commonValuesFound = true - if ref.Optional != false { - t.Errorf("expected values-common ConfigMap to be Optional=false, got %v", ref.Optional) - } - } - } - - if !commonValuesFound { - t.Error("expected values-common ConfigMap reference to be present") - } - }) - - t.Run("WithComponentValuesConfigMap", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock that global values.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize", "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And mock the values.yaml content with ingress component - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "values.yaml") { - return []byte(`ingress: - key: value`), nil - } - return nil, os.ErrNotExist - } - - handler.shims.YamlUnmarshal = func(data []byte, v interface{}) error { - values := map[string]any{ - "ingress": map[string]any{ - "key": "value", - }, - } - reflect.ValueOf(v).Elem().Set(reflect.ValueOf(values)) - return nil - } - - // And a kustomization with component name - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with ConfigMap references - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") - } - - // And it should have the component-specific ConfigMap reference - componentValuesFound := false - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "values-ingress" { - componentValuesFound = true - if ref.Optional != false { - t.Errorf("expected values-ingress ConfigMap to be Optional=false, got %v", ref.Optional) - } - break - } - } - - if !componentValuesFound { - t.Error("expected values-ingress ConfigMap reference to be present") - } - }) - - t.Run("WithExistingPostBuild", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock that global values.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize", "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And a kustomization with existing PostBuild - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - PostBuild: &blueprintv1alpha1.PostBuild{ - Substitute: map[string]string{ - "VAR1": "value1", - "VAR2": "value2", - }, - SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ - { - Kind: "ConfigMap", - Name: "existing-config", - Optional: true, - }, - }, - }, - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with both existing and new references - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") - } - - // And it should preserve existing Substitute values - if len(result.Spec.PostBuild.Substitute) != 2 { - t.Errorf("expected 2 Substitute values, got %d", len(result.Spec.PostBuild.Substitute)) - } - if result.Spec.PostBuild.Substitute["VAR1"] != "value1" { - t.Errorf("expected VAR1 to be 'value1', got '%s'", result.Spec.PostBuild.Substitute["VAR1"]) - } - if result.Spec.PostBuild.Substitute["VAR2"] != "value2" { - t.Errorf("expected VAR2 to be 'value2', got '%s'", result.Spec.PostBuild.Substitute["VAR2"]) - } - - // And it should have the correct SubstituteFrom references - commonValuesFound := false - existingConfigFound := false - - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "values-common" { - commonValuesFound = true - } - if ref.Kind == "ConfigMap" && ref.Name == "existing-config" { - existingConfigFound = true - if ref.Optional != true { - t.Errorf("expected existing-config to be Optional=true, got %v", ref.Optional) - } - } - } - - if !commonValuesFound { - t.Error("expected values-common ConfigMap reference to be present") - } - if !existingConfigFound { - t.Error("expected existing-config ConfigMap reference to be preserved") - } - }) - - t.Run("WithoutValuesConfigMaps", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock that no config.yaml files exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // And a kustomization - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with only common ConfigMap reference - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") - } - - // And it should only have the common ConfigMap reference - if len(result.Spec.PostBuild.SubstituteFrom) != 1 { - t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) - } - - ref := result.Spec.PostBuild.SubstituteFrom[0] - if ref.Kind != "ConfigMap" { - t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) - } - if ref.Name != "values-common" { - t.Errorf("expected Name to be 'values-common', got '%s'", ref.Name) - } - if ref.Optional != false { - t.Errorf("expected Optional to be false, got %v", ref.Optional) - } - }) - - t.Run("ConfigRootError", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root that fails - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "", os.ErrNotExist - } - - // And a kustomization - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should still have PostBuild with only blueprint ConfigMap reference - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") - } - - // And it should only have the blueprint ConfigMap reference (no values ConfigMaps due to error) - if len(result.Spec.PostBuild.SubstituteFrom) != 1 { - t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) - } - - ref := result.Spec.PostBuild.SubstituteFrom[0] - if ref.Kind != "ConfigMap" { - t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) - } - if ref.Name != "values-common" { - t.Errorf("expected Name to be 'values-common', got '%s'", ref.Name) - } - }) -} - -func TestBaseBlueprintHandler_toFluxKustomization_Comprehensive(t *testing.T) { - // Given a handler with mocks - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - handler.configHandler = mocks.ConfigHandler - handler.kubernetesManager = mocks.KubernetesManager - return handler - } - - t.Run("BasicKustomizationConversion", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a basic kustomization - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have correct basic fields - if result.Name != "test-kustomization" { - t.Errorf("expected Name to be 'test-kustomization', got '%s'", result.Name) - } - if result.Namespace != "test-namespace" { - t.Errorf("expected Namespace to be 'test-namespace', got '%s'", result.Namespace) - } - if result.Spec.Path != "test/path" { - t.Errorf("expected Path to be 'test/path', got '%s'", result.Spec.Path) - } - if result.Spec.SourceRef.Name != "test-source" { - t.Errorf("expected SourceRef.Name to be 'test-source', got '%s'", result.Spec.SourceRef.Name) - } - if result.Spec.SourceRef.Kind != "GitRepository" { - t.Errorf("expected SourceRef.Kind to be 'GitRepository', got '%s'", result.Spec.SourceRef.Kind) - } - if result.Spec.Interval.Duration != 5*time.Minute { - t.Errorf("expected Interval to be 5 minutes, got %v", result.Spec.Interval.Duration) - } - if result.Spec.RetryInterval.Duration != 1*time.Minute { - t.Errorf("expected RetryInterval to be 1 minute, got %v", result.Spec.RetryInterval.Duration) - } - if result.Spec.Timeout.Duration != 10*time.Minute { - t.Errorf("expected Timeout to be 10 minutes, got %v", result.Spec.Timeout.Duration) - } - if result.Spec.Force != false { - t.Errorf("expected Force to be false, got %v", result.Spec.Force) - } - if result.Spec.Wait != false { - t.Errorf("expected Wait to be false, got %v", result.Spec.Wait) - } - if result.Spec.Prune != true { - t.Errorf("expected Prune to be true, got %v", result.Spec.Prune) - } - if result.Spec.DeletionPolicy != "WaitForTermination" { - t.Errorf("expected DeletionPolicy to be 'WaitForTermination', got '%s'", result.Spec.DeletionPolicy) - } - }) - - t.Run("WithDependsOn", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization with dependencies - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - DependsOn: []string{"dependency1", "dependency2"}, - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have correct dependencies - if len(result.Spec.DependsOn) != 2 { - t.Errorf("expected 2 dependencies, got %d", len(result.Spec.DependsOn)) - } - - expectedDeps := map[string]bool{ - "dependency1": false, - "dependency2": false, - } - - for _, dep := range result.Spec.DependsOn { - if dep.Namespace != "test-namespace" { - t.Errorf("expected dependency namespace to be 'test-namespace', got '%s'", dep.Namespace) - } - expectedDeps[dep.Name] = true - } - - for depName, found := range expectedDeps { - if !found { - t.Errorf("expected dependency '%s' not found", depName) - } - } - }) - - t.Run("WithOCISource", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint with OCI repository - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "oci://registry.example.com/test/repo", - }, - } - - // And a kustomization with OCI source - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "oci://registry.example.com/test/source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have OCI source type - if result.Spec.SourceRef.Kind != "OCIRepository" { - t.Errorf("expected SourceRef.Kind to be 'OCIRepository', got '%s'", result.Spec.SourceRef.Kind) - } - if result.Spec.SourceRef.Name != "oci://registry.example.com/test/source" { - t.Errorf("expected SourceRef.Name to be 'oci://registry.example.com/test/source', got '%s'", result.Spec.SourceRef.Name) - } - }) - - t.Run("WithDestroyPolicy", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization with destroy policy - destroy := true - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Destroy: &destroy, - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have WaitForTermination deletion policy - if result.Spec.DeletionPolicy != "WaitForTermination" { - t.Errorf("expected DeletionPolicy to be 'WaitForTermination', got '%s'", result.Spec.DeletionPolicy) - } - }) - - t.Run("WithPatchFromFile", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock patch file content - patchContent := `apiVersion: v1 -kind: ConfigMap -metadata: - name: test-config - namespace: test-namespace -data: - key: value` - - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "patch.yaml") { - return []byte(patchContent), nil - } - return nil, os.ErrNotExist - } - - // And a kustomization with patch from file - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "patch.yaml", - }, - }, - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have the patch - if len(result.Spec.Patches) != 1 { - t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) - } - - patch := result.Spec.Patches[0] - if patch.Patch != patchContent { - t.Errorf("expected patch content to match, got '%s'", patch.Patch) - } - - if patch.Target == nil { - t.Error("expected patch target to be set") - } else { - if patch.Target.Kind != "ConfigMap" { - t.Errorf("expected target kind to be 'ConfigMap', got '%s'", patch.Target.Kind) - } - if patch.Target.Name != "test-config" { - t.Errorf("expected target name to be 'test-config', got '%s'", patch.Target.Name) - } - if patch.Target.Namespace != "test-namespace" { - t.Errorf("expected target namespace to be 'test-namespace', got '%s'", patch.Target.Namespace) - } - } - }) - - t.Run("WithInlinePatch", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization with inline patch - inlinePatch := `apiVersion: v1 -kind: ConfigMap -metadata: - name: inline-config -data: - inline: value` - - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Patch: inlinePatch, - }, - }, - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have the inline patch - if len(result.Spec.Patches) != 1 { - t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) - } - - patch := result.Spec.Patches[0] - if patch.Patch != inlinePatch { - t.Errorf("expected patch content to match, got '%s'", patch.Patch) - } - - if patch.Target != nil { - t.Error("expected patch target to be nil for inline patch") - } - }) - - t.Run("WithPatchTarget", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization with patch target - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Patch: "patch content", - Target: &kustomize.Selector{ - Kind: "Deployment", - Name: "test-deployment", - Namespace: "custom-namespace", - }, - }, - }, - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have the patch with target - if len(result.Spec.Patches) != 1 { - t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) - } - - patch := result.Spec.Patches[0] - if patch.Patch != "patch content" { - t.Errorf("expected patch content to match, got '%s'", patch.Patch) - } - - if patch.Target == nil { - t.Error("expected patch target to be set") - } else { - if patch.Target.Kind != "Deployment" { - t.Errorf("expected target kind to be 'Deployment', got '%s'", patch.Target.Kind) - } - if patch.Target.Name != "test-deployment" { - t.Errorf("expected target name to be 'test-deployment', got '%s'", patch.Target.Name) - } - if patch.Target.Namespace != "custom-namespace" { - t.Errorf("expected target namespace to be 'custom-namespace', got '%s'", patch.Target.Namespace) - } - } - }) - - t.Run("WithMultiplePatches", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization with multiple patches - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Patch: "patch1", - }, - { - Patch: "patch2", - Target: &kustomize.Selector{ - Kind: "Service", - Name: "test-service", - }, - }, - }, - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have both patches - if len(result.Spec.Patches) != 2 { - t.Errorf("expected 2 patches, got %d", len(result.Spec.Patches)) - } - - if result.Spec.Patches[0].Patch != "patch1" { - t.Errorf("expected first patch content to be 'patch1', got '%s'", result.Spec.Patches[0].Patch) - } - - if result.Spec.Patches[1].Patch != "patch2" { - t.Errorf("expected second patch content to be 'patch2', got '%s'", result.Spec.Patches[1].Patch) - } - - if result.Spec.Patches[1].Target == nil { - t.Error("expected second patch target to be set") - } else { - if result.Spec.Patches[1].Target.Kind != "Service" { - t.Errorf("expected second patch target kind to be 'Service', got '%s'", result.Spec.Patches[1].Target.Kind) - } - if result.Spec.Patches[1].Target.Name != "test-service" { - t.Errorf("expected second patch target name to be 'test-service', got '%s'", result.Spec.Patches[1].Target.Name) - } - } - }) - - t.Run("WithComponents", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization with components - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Components: []string{"component1", "component2"}, - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have the components - if len(result.Spec.Components) != 2 { - t.Errorf("expected 2 components, got %d", len(result.Spec.Components)) - } - - expectedComponents := map[string]bool{ - "component1": false, - "component2": false, - } - - for _, component := range result.Spec.Components { - expectedComponents[component] = true - } - - for componentName, found := range expectedComponents { - if !found { - t.Errorf("expected component '%s' not found", componentName) - } - } - }) - - t.Run("WithCustomPrune", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization with custom prune setting - prune := false - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Prune: &prune, - } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have the custom prune setting - if result.Spec.Prune != false { - t.Errorf("expected Prune to be false, got %v", result.Spec.Prune) - } - }) -} - -func TestBaseBlueprintHandler_applyConfigMap_WithBuildID(t *testing.T) { - mocks := setupMocks(t, &SetupOptions{ - ConfigStr: ` -contexts: - test: - id: "test-id" - dns: - domain: "test.com" - network: - loadbalancer_ips: - start: "10.0.0.1" - end: "10.0.0.10" - docker: - registry_url: "registry.test" - cluster: - workers: - volumes: ["/tmp:/data"] -`, - }) - - handler := NewBlueprintHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("failed to initialize handler: %v", err) - } - - // Set up build ID by mocking the file system - testBuildID := "build-1234567890" - projectRoot, err := mocks.Shell.GetProjectRoot() - if err != nil { - t.Fatalf("failed to get project root: %v", err) - } - buildIDPath := filepath.Join(projectRoot, ".windsor", ".build-id") - - // Mock the file system to return our test build ID - handler.shims.Stat = func(path string) (os.FileInfo, error) { - if path == buildIDPath { - return mockFileInfo{name: ".build-id", isDir: false}, nil - } - return nil, os.ErrNotExist - } - handler.shims.ReadFile = func(path string) ([]byte, error) { - if path == buildIDPath { - return []byte(testBuildID), nil - } - return []byte{}, nil - } - - // Mock the kubernetes manager to capture the ConfigMap data - var capturedData map[string]string - mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - capturedData = data - return nil - } - - // Call applyValuesConfigMaps - if err := handler.applyValuesConfigMaps(); err != nil { - t.Fatalf("failed to apply ConfigMap: %v", err) - } - - // Verify BUILD_ID is included in the ConfigMap data - if capturedData == nil { - t.Fatal("ConfigMap data was not captured") - } - - buildID, exists := capturedData["BUILD_ID"] - if !exists { - t.Fatal("BUILD_ID not found in ConfigMap data") - } - - if buildID != testBuildID { - t.Errorf("expected BUILD_ID to be %s, got %s", testBuildID, buildID) - } - - // Verify other expected fields are present - expectedFields := []string{"DOMAIN", "CONTEXT", "CONTEXT_ID", "LOADBALANCER_IP_RANGE", "REGISTRY_URL"} - for _, field := range expectedFields { - if _, exists := capturedData[field]; !exists { - t.Errorf("expected field %s not found in ConfigMap data", field) - } - } -} - -func TestBaseBlueprintHandler_applyConfigMap_WithoutBuildID(t *testing.T) { - mocks := setupMocks(t, &SetupOptions{ - ConfigStr: ` -contexts: - test: - id: "test-id" - dns: - domain: "test.com" - network: - loadbalancer_ips: - start: "10.0.0.1" - end: "10.0.0.10" - docker: - registry_url: "registry.test" - cluster: - workers: - volumes: ["/tmp:/data"] -`, - }) - - handler := NewBlueprintHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("failed to initialize handler: %v", err) - } - - // Mock the file system to simulate missing .build-id file - projectRoot, err := mocks.Shell.GetProjectRoot() - if err != nil { - t.Fatalf("failed to get project root: %v", err) - } - buildIDPath := filepath.Join(projectRoot, ".windsor", ".build-id") - - // Mock the file system to return file not found for .build-id - handler.shims.Stat = func(path string) (os.FileInfo, error) { - if path == buildIDPath { - return nil, os.ErrNotExist - } - return nil, os.ErrNotExist - } - handler.shims.ReadFile = func(path string) ([]byte, error) { - if path == buildIDPath { - return nil, os.ErrNotExist - } - return []byte{}, nil - } - - // Mock the kubernetes manager to capture the ConfigMap data - var capturedData map[string]string - mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - capturedData = data - return nil - } - - // Call applyValuesConfigMaps - this should not cause an error - if err := handler.applyValuesConfigMaps(); err != nil { - t.Fatalf("failed to apply ConfigMap: %v", err) - } - - // Verify BUILD_ID is not included in the ConfigMap data when file doesn't exist - if capturedData == nil { - t.Fatal("ConfigMap data was not captured") - } - - buildID, exists := capturedData["BUILD_ID"] - if exists { - t.Errorf("expected BUILD_ID to not be present in ConfigMap data when file doesn't exist, but it was found with value '%s'", buildID) - } - - // Verify other expected fields are present - expectedFields := []string{"DOMAIN", "CONTEXT", "CONTEXT_ID", "LOADBALANCER_IP_RANGE", "REGISTRY_URL"} - for _, field := range expectedFields { - if _, exists := capturedData[field]; !exists { - t.Errorf("expected field %s not found in ConfigMap data", field) - } - } -} - -// ============================================================================= -// New Functionality Tests -// ============================================================================= - -func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - handler.shims = NewShims() - handler.configHandler = config.NewMockConfigHandler() - return handler - } - - t.Run("WithRenderedDataOnly", func(t *testing.T) { - // Given a handler with rendered patch data only - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/patches/test": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - "namespace": "test-namespace", - }, - "data": map[string]any{ - "key": "value", - }, - }, - } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test yaml"), nil - } - // When resolving patch from path - content, target := handler.resolvePatchFromPath("test", "default-namespace") - // Then content should be returned and target should be extracted - if content != "test yaml" { - t.Errorf("Expected content = 'test yaml', got = '%s'", content) - } - if target == nil { - t.Error("Expected target to be extracted") - } - if target.Kind != "ConfigMap" { - t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) - } - if target.Name != "test-config" { - t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) - } - if target.Namespace != "test-namespace" { - t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) - } - }) - - t.Run("WithNoData", func(t *testing.T) { - // Given a handler with no data - handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "", fmt.Errorf("config root error") - } - // When resolving patch from path - content, target := handler.resolvePatchFromPath("test", "default-namespace") - // Then empty content and nil target should be returned - if content != "" { - t.Errorf("Expected empty content, got = '%s'", content) - } - if target != nil { - t.Error("Expected target to be nil") - } - }) - - t.Run("WithYamlExtension", func(t *testing.T) { - // Given a handler with patch path containing .yaml extension - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/patches/test": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - }, - } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test yaml"), nil - } - // When resolving patch from path with .yaml extension - content, target := handler.resolvePatchFromPath("test.yaml", "default-namespace") - // Then content should be returned and target should be extracted - if content != "test yaml" { - t.Errorf("Expected content = 'test yaml', got = '%s'", content) - } - if target == nil { - t.Error("Expected target to be extracted") - } - }) - - t.Run("WithYmlExtension", func(t *testing.T) { - // Given a handler with patch path containing .yml extension - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/patches/test": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - }, - } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test yaml"), nil - } - // When resolving patch from path with .yml extension - content, target := handler.resolvePatchFromPath("test.yml", "default-namespace") - // Then content should be returned and target should be extracted - if content != "test yaml" { - t.Errorf("Expected content = 'test yaml', got = '%s'", content) - } - if target == nil { - t.Error("Expected target to be extracted") - } - }) - - t.Run("WithBothRenderedAndUserDataMerge", func(t *testing.T) { - // Given a handler with both rendered and user data that can be merged - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/patches/test": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "rendered-config", - "namespace": "rendered-namespace", - }, - "data": map[string]any{ - "rendered-key": "rendered-value", - }, - }, - } - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`apiVersion: v1 -kind: ConfigMap -metadata: - name: user-config - namespace: user-namespace -data: - user-key: user-value`), nil - } - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "user-config", - "namespace": "user-namespace", - }, - "data": map[string]any{ - "user-key": "user-value", - }, - } - return nil - } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("merged yaml"), nil - } - // When resolving patch from path - content, target := handler.resolvePatchFromPath("test", "default-namespace") - // Then merged content should be returned and target should be extracted from merged data - if content != "merged yaml" { - t.Errorf("Expected content = 'merged yaml', got = '%s'", content) - } - if target == nil { - t.Error("Expected target to be extracted") - } - if target.Name != "user-config" { - t.Errorf("Expected target name = 'user-config', got = '%s'", target.Name) - } - if target.Namespace != "user-namespace" { - t.Errorf("Expected target namespace = 'user-namespace', got = '%s'", target.Namespace) - } - }) -} - -func TestBaseBlueprintHandler_extractTargetFromPatchData(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - return handler - } - - t.Run("ValidPatchData", func(t *testing.T) { - // Given valid patch data with all required fields - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - "namespace": "test-namespace", - }, - } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be extracted correctly - if target == nil { - t.Error("Expected target to be extracted") - } - if target.Kind != "ConfigMap" { - t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) - } - if target.Name != "test-config" { - t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) - } - if target.Namespace != "test-namespace" { - t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) - } - }) - - t.Run("WithCustomNamespace", func(t *testing.T) { - // Given patch data with custom namespace - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - "namespace": "custom-namespace", - }, - } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then custom namespace should be used - if target.Namespace != "custom-namespace" { - t.Errorf("Expected target namespace = 'custom-namespace', got = '%s'", target.Namespace) - } - }) - - t.Run("MissingKind", func(t *testing.T) { - // Given patch data missing kind field - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "metadata": map[string]any{ - "name": "test-config", - }, - } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when kind is missing") - } - }) - - t.Run("MissingMetadata", func(t *testing.T) { - // Given patch data missing metadata field - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when metadata is missing") - } - }) - - t.Run("MissingName", func(t *testing.T) { - // Given patch data missing name field - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{}, - } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when name is missing") - } - }) - - t.Run("InvalidKindType", func(t *testing.T) { - // Given patch data with invalid kind type - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": 42, - "metadata": map[string]any{ - "name": "test-config", - }, - } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when kind type is invalid") - } - }) - - t.Run("InvalidMetadataType", func(t *testing.T) { - // Given patch data with invalid metadata type - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": "not a map", - } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when metadata type is invalid") - } - }) - - t.Run("InvalidNameType", func(t *testing.T) { - // Given patch data with invalid name type - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": 42, - }, - } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when name type is invalid") - } - }) -} - -func TestBaseBlueprintHandler_extractTargetFromPatchContent(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - return handler - } - - t.Run("ValidYamlContent", func(t *testing.T) { - // Given valid YAML content - handler := setup(t) - content := `apiVersion: v1 -kind: ConfigMap -metadata: - name: test-config - namespace: test-namespace` - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then target should be extracted correctly - if target == nil { - t.Error("Expected target to be extracted") - } - if target.Name != "test-config" { - t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) - } - }) - - t.Run("MultipleDocuments", func(t *testing.T) { - // Given YAML with multiple documents - handler := setup(t) - content := `--- -apiVersion: v1 -kind: ConfigMap -metadata: - name: first-config ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: second-config` - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then first valid target should be extracted - if target == nil { - t.Error("Expected target to be extracted") - } - if target.Name != "first-config" { - t.Errorf("Expected target name = 'first-config', got = '%s'", target.Name) - } - }) - - t.Run("InvalidYamlContent", func(t *testing.T) { - // Given invalid YAML content - handler := setup(t) - content := `invalid: yaml: content: with: colons: everywhere` - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil for invalid YAML") - } - }) - - t.Run("EmptyContent", func(t *testing.T) { - // Given empty content - handler := setup(t) - content := "" - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil for empty content") - } - }) - - t.Run("NoValidTargets", func(t *testing.T) { - // Given YAML with no valid targets - handler := setup(t) - content := `apiVersion: v1 -kind: ConfigMap -# Missing metadata.name` - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when no valid targets") - } - }) -} - -func TestBaseBlueprintHandler_hasComponentValues(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - handler.shims = NewShims() - handler.configHandler = config.NewMockConfigHandler() - return handler - } - - t.Run("TemplateComponentExists", func(t *testing.T) { - // Given handler with component in template data - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/values": map[string]any{ - "test-component": map[string]any{ - "key": "value", - }, - }, - } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return true - if !exists { - t.Error("Expected component to exist in template data") - } - }) - - t.Run("UserComponentExists", func(t *testing.T) { - // Given handler with component in user file - handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: "config.yaml", isDir: false}, nil - } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`test-component: - key: value`), nil - } - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "test-component": map[string]any{ - "key": "value", - }, - } - return nil - } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return true - if !exists { - t.Error("Expected component to exist in user file") - } - }) - - t.Run("BothTemplateAndUserExist", func(t *testing.T) { - // Given handler with component in both template and user data - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/values": map[string]any{ - "test-component": map[string]any{ - "template-key": "template-value", - }, - }, - } - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: "config.yaml", isDir: false}, nil - } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`test-component: - user-key: user-value`), nil - } - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "test-component": map[string]any{ - "user-key": "user-value", - }, - } - return nil - } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return true - if !exists { - t.Error("Expected component to exist in both sources") - } - }) - - t.Run("NoComponentExists", func(t *testing.T) { - // Given handler with no component data - handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return false - if exists { - t.Error("Expected component to not exist") - } - }) - - t.Run("ConfigRootError", func(t *testing.T) { - // Given handler with config root error - handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "", fmt.Errorf("config root error") - } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return false - if exists { - t.Error("Expected component to not exist when config root fails") - } - }) - - t.Run("FileNotExists", func(t *testing.T) { - // Given handler with file not existing - handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return false - if exists { - t.Error("Expected component to not exist when file doesn't exist") - } - }) - - t.Run("InvalidValuesFile", func(t *testing.T) { - // Given handler with invalid values file - handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: "config.yaml", isDir: false}, nil - } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte("invalid yaml"), nil - } - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - return fmt.Errorf("invalid yaml") - } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return false - if exists { - t.Error("Expected component to not exist when values file is invalid") - } - }) -} - -func TestBaseBlueprintHandler_deepMergeMaps(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - return handler - } - - t.Run("SimpleMerge", func(t *testing.T) { - // Given base and overlay maps with simple values - handler := setup(t) - base := map[string]any{ - "key1": "base-value1", - "key2": "base-value2", - } - overlay := map[string]any{ - "key2": "overlay-value2", - "key3": "overlay-value3", - } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then result should contain merged values - if result["key1"] != "base-value1" { - t.Errorf("Expected key1 = 'base-value1', got = '%v'", result["key1"]) - } - if result["key2"] != "overlay-value2" { - t.Errorf("Expected key2 = 'overlay-value2', got = '%v'", result["key2"]) - } - if result["key3"] != "overlay-value3" { - t.Errorf("Expected key3 = 'overlay-value3', got = '%v'", result["key3"]) - } - }) - - t.Run("NestedMapMerge", func(t *testing.T) { - // Given base and overlay maps with nested maps - handler := setup(t) - base := map[string]any{ - "nested": map[string]any{ - "base-key": "base-value", - }, - } - overlay := map[string]any{ - "nested": map[string]any{ - "overlay-key": "overlay-value", - }, - } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then nested maps should be merged - nested := result["nested"].(map[string]any) - if nested["base-key"] != "base-value" { - t.Errorf("Expected nested.base-key = 'base-value', got = '%v'", nested["base-key"]) - } - if nested["overlay-key"] != "overlay-value" { - t.Errorf("Expected nested.overlay-key = 'overlay-value', got = '%v'", nested["overlay-key"]) - } - }) - - t.Run("OverlayPrecedence", func(t *testing.T) { - // Given base and overlay maps with conflicting keys - handler := setup(t) - base := map[string]any{ - "key": "base-value", - } - overlay := map[string]any{ - "key": "overlay-value", - } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then overlay value should take precedence - if result["key"] != "overlay-value" { - t.Errorf("Expected key = 'overlay-value', got = '%v'", result["key"]) - } - }) - - t.Run("DeepNestedMerge", func(t *testing.T) { - // Given base and overlay maps with deeply nested maps - handler := setup(t) - base := map[string]any{ - "level1": map[string]any{ - "level2": map[string]any{ - "base-key": "base-value", - }, - }, - } - overlay := map[string]any{ - "level1": map[string]any{ - "level2": map[string]any{ - "overlay-key": "overlay-value", - }, - }, - } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then deeply nested maps should be merged - level1 := result["level1"].(map[string]any) - level2 := level1["level2"].(map[string]any) - if level2["base-key"] != "base-value" { - t.Errorf("Expected level2.base-key = 'base-value', got = '%v'", level2["base-key"]) - } - if level2["overlay-key"] != "overlay-value" { - t.Errorf("Expected level2.overlay-key = 'overlay-value', got = '%v'", level2["overlay-key"]) - } - }) - - t.Run("EmptyMaps", func(t *testing.T) { - // Given empty base and overlay maps - handler := setup(t) - base := map[string]any{} - overlay := map[string]any{} - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then result should be empty - if len(result) != 0 { - t.Errorf("Expected empty result, got %d items", len(result)) - } - }) - - t.Run("NonMapOverlay", func(t *testing.T) { - // Given base map and non-map overlay value - handler := setup(t) - base := map[string]any{ - "key": map[string]any{ - "nested": "value", - }, - } - overlay := map[string]any{ - "key": "string-value", - } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then overlay value should replace base value - if result["key"] != "string-value" { - t.Errorf("Expected key = 'string-value', got = '%v'", result["key"]) - } - }) - - t.Run("MixedTypes", func(t *testing.T) { - // Given base and overlay maps with mixed types - handler := setup(t) - base := map[string]any{ - "string": "base-string", - "number": 42, - "nested": map[string]any{ - "key": "base-nested", - }, - } - overlay := map[string]any{ - "string": "overlay-string", - "bool": true, - "nested": map[string]any{ - "overlay-key": "overlay-nested", - }, - } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then all values should be merged correctly - if result["string"] != "overlay-string" { - t.Errorf("Expected string = 'overlay-string', got = '%v'", result["string"]) - } - if result["number"] != 42 { - t.Errorf("Expected number = 42, got = '%v'", result["number"]) - } - if result["bool"] != true { - t.Errorf("Expected bool = true, got = '%v'", result["bool"]) - } - nested := result["nested"].(map[string]any) - if nested["key"] != "base-nested" { - t.Errorf("Expected nested.key = 'base-nested', got = '%v'", nested["key"]) - } - if nested["overlay-key"] != "overlay-nested" { - t.Errorf("Expected nested.overlay-key = 'overlay-nested', got = '%v'", nested["overlay-key"]) - } - }) -} - func TestBaseBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() @@ -6688,372 +3696,3 @@ func TestBaseBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { } }) } - -// ============================================================================= -// Validation Tests -// ============================================================================= - -func TestBaseBlueprintHandler_validateValuesForSubstitution(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - return &BaseBlueprintHandler{} - } - - t.Run("AcceptsValidScalarValues", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "string_value": "test", - "int_value": 42, - "int8_value": int8(8), - "int16_value": int16(16), - "int32_value": int32(32), - "int64_value": int64(64), - "uint_value": uint(42), - "uint8_value": uint8(8), - "uint16_value": uint16(16), - "uint32_value": uint32(32), - "uint64_value": uint64(64), - "float32_value": float32(3.14), - "float64_value": 3.14159, - "bool_value": true, - } - - err := handler.validateValuesForSubstitution(values) - if err != nil { - t.Errorf("Expected no error for valid scalar values, got: %v", err) - } - }) - - t.Run("AcceptsOneLevelOfMapWithScalarValues", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "top_level_string": "value", - "scalar_map": map[string]any{ - "nested_string": "nested_value", - "nested_int": 123, - "nested_bool": false, - }, - "another_top_level": 456, - } - - err := handler.validateValuesForSubstitution(values) - if err != nil { - t.Errorf("Expected no error for map with scalar values, got: %v", err) - } - }) - - t.Run("RejectsNestedMaps", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "top_level": map[string]any{ - "second_level": map[string]any{ - "third_level": "value", - }, - }, - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for nested maps") - } - - if !strings.Contains(err.Error(), "can only contain scalar values in maps") { - t.Errorf("Expected error about scalar values only in maps, got: %v", err) - } - if !strings.Contains(err.Error(), "top_level.second_level") { - t.Errorf("Expected error to mention the nested key path, got: %v", err) - } - }) - - t.Run("RejectsSlices", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "valid_string": "test", - "invalid_slice": []any{"item1", "item2", "item3"}, - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for slice values") - } - - if !strings.Contains(err.Error(), "cannot contain slices") { - t.Errorf("Expected error about slices, got: %v", err) - } - if !strings.Contains(err.Error(), "invalid_slice") { - t.Errorf("Expected error to mention the slice key, got: %v", err) - } - }) - - t.Run("RejectsSlicesInNestedMaps", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "nested_map": map[string]any{ - "valid_value": "test", - "invalid_slice": []any{"item1", "item2"}, // Use []any to match the type check - }, - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for slice in nested map") - } - - if !strings.Contains(err.Error(), "cannot contain slices") { - t.Errorf("Expected error about slices, got: %v", err) - } - if !strings.Contains(err.Error(), "nested_map.invalid_slice") { - t.Errorf("Expected error to mention the nested slice key path, got: %v", err) - } - }) - - t.Run("RejectsTypedSlices", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "string_slice": []string{"item1", "item2"}, - "int_slice": []int{1, 2, 3}, - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for typed slices") - } - - // After the fix, typed slices should now get the specific slice error message - if !strings.Contains(err.Error(), "cannot contain slices") { - t.Errorf("Expected error about slices for typed slices, got: %v", err) - } - }) - - t.Run("RejectsUnsupportedTypes", func(t *testing.T) { - handler := setup(t) - - // Test with a struct (unsupported type) - type customStruct struct { - Field string - } - - values := map[string]any{ - "valid_string": "test", - "invalid_struct": customStruct{Field: "value"}, - "invalid_function": func() {}, - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for unsupported types") - } - - if !strings.Contains(err.Error(), "can only contain strings, numbers, booleans, or maps of scalar types") { - t.Errorf("Expected error about unsupported types, got: %v", err) - } - }) - - t.Run("RejectsUnsupportedTypesInNestedMaps", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "nested_map": map[string]any{ - "valid_value": "test", - "invalid_value": make(chan int), // Channel is unsupported - }, - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for unsupported type in nested map") - } - - if !strings.Contains(err.Error(), "can only contain scalar values in maps") { - t.Errorf("Expected error about scalar values only in maps, got: %v", err) - } - if !strings.Contains(err.Error(), "nested_map.invalid_value") { - t.Errorf("Expected error to mention the nested key path, got: %v", err) - } - }) - - t.Run("RejectsSlicesInMaps", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "config": map[string]any{ - "valid_key": "test", - "slice_key": []string{"item1", "item2"}, - }, - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for slices in maps") - } - - if !strings.Contains(err.Error(), "cannot contain slices") { - t.Errorf("Expected error about slices, got: %v", err) - } - if !strings.Contains(err.Error(), "config.slice_key") { - t.Errorf("Expected error to mention the nested slice key path, got: %v", err) - } - }) - - t.Run("HandlesEmptyValues", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{} - - err := handler.validateValuesForSubstitution(values) - if err != nil { - t.Errorf("Expected no error for empty values, got: %v", err) - } - }) - - t.Run("HandlesEmptyNestedMaps", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "empty_nested": map[string]any{}, - "valid_value": "test", - } - - err := handler.validateValuesForSubstitution(values) - if err != nil { - t.Errorf("Expected no error for empty nested maps, got: %v", err) - } - }) - - t.Run("HandlesNilValues", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "nil_value": nil, - "valid_value": "test", - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for nil values") - } - - if !strings.Contains(err.Error(), "cannot contain nil values") { - t.Errorf("Expected error about nil values, got: %v", err) - } - }) - - t.Run("HandlesNilValuesInMaps", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "config": map[string]any{ - "valid_key": "test", - "nil_key": nil, - }, - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for nil values in maps") - } - - if !strings.Contains(err.Error(), "cannot contain nil values") { - t.Errorf("Expected error about nil values, got: %v", err) - } - if !strings.Contains(err.Error(), "config.nil_key") { - t.Errorf("Expected error to mention the nested nil key path, got: %v", err) - } - }) - - t.Run("ValidatesComplexScenario", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "app_name": "my-app", - "app_version": "1.2.3", - "replicas": 3, - "enabled": true, - "config": map[string]any{ - "database_url": "postgres://localhost:5432/mydb", - "cache_enabled": true, - "max_connections": 100, - "timeout_seconds": 30.5, - "debug_mode": false, - }, - "resources": map[string]any{ - "cpu_limit": "500m", - "memory_limit": "512Mi", - "cpu_request": "100m", - "memory_request": "128Mi", - }, - } - - err := handler.validateValuesForSubstitution(values) - if err != nil { - t.Errorf("Expected no error for complex valid scenario, got: %v", err) - } - }) - - t.Run("RejectsComplexScenarioWithInvalidNesting", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "app_name": "my-app", - "config": map[string]any{ - "database": map[string]any{ // Maps cannot contain other maps - "host": "localhost", - "port": 5432, - }, - }, - } - - err := handler.validateValuesForSubstitution(values) - if err == nil { - t.Error("Expected error for invalid nesting in complex scenario") - } - - if !strings.Contains(err.Error(), "can only contain scalar values in maps") { - t.Errorf("Expected error about scalar values only in maps, got: %v", err) - } - if !strings.Contains(err.Error(), "config.database") { - t.Errorf("Expected error to mention the nested path, got: %v", err) - } - }) - - t.Run("HandlesSpecialNumericTypes", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "zero_int": 0, - "negative_int": -42, - "zero_float": 0.0, - "negative_float": -3.14, - "large_uint64": uint64(18446744073709551615), // Max uint64 - "small_int8": int8(-128), // Min int8 - } - - err := handler.validateValuesForSubstitution(values) - if err != nil { - t.Errorf("Expected no error for special numeric types, got: %v", err) - } - }) - - t.Run("HandlesSpecialStringValues", func(t *testing.T) { - handler := setup(t) - - values := map[string]any{ - "empty_string": "", - "whitespace": " ", - "newlines": "line1\nline2", - "unicode": "Hello δΈ–η•Œ 🌍", - "special_chars": "!@#$%^&*()_+-={}[]|\\:;\"'<>?,./", - } - - err := handler.validateValuesForSubstitution(values) - if err != nil { - t.Errorf("Expected no error for special string values, got: %v", err) - } - }) -} diff --git a/pkg/blueprint/mock_blueprint_handler_test.go b/pkg/blueprint/mock_blueprint_handler_test.go index 181a7c7fd..f88b2608f 100644 --- a/pkg/blueprint/mock_blueprint_handler_test.go +++ b/pkg/blueprint/mock_blueprint_handler_test.go @@ -6,6 +6,7 @@ import ( "testing" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/artifact" "github.com/windsorcli/cli/pkg/di" ) @@ -673,3 +674,35 @@ func TestMockBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { } }) } + +func TestMockBlueprintHandler_LoadData(t *testing.T) { + t.Run("WithFuncSet", func(t *testing.T) { + // Given a mock blueprint handler with LoadDataFunc set + handler := NewMockBlueprintHandler(di.NewInjector()) + expectedError := fmt.Errorf("mock load data error") + handler.LoadDataFunc = func(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error { + return expectedError + } + + // When LoadData is called + err := handler.LoadData(map[string]any{}) + + // Then it should return the expected error + if err != expectedError { + t.Errorf("expected error %v, got %v", expectedError, err) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a mock blueprint handler without LoadDataFunc set + handler := NewMockBlueprintHandler(di.NewInjector()) + + // When LoadData is called + err := handler.LoadData(map[string]any{}) + + // Then it should return nil + if err != nil { + t.Errorf("expected nil, got %v", err) + } + }) +}