From 5309eef07cbbaeea27f9feb01e6fb532ac4ef88b Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Sat, 15 Nov 2025 10:16:22 -0500 Subject: [PATCH 1/8] Increase coverage of composer Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- pkg/composer/composer_test.go | 1288 +++++++++++++++++++++++++++++++-- 1 file changed, 1224 insertions(+), 64 deletions(-) diff --git a/pkg/composer/composer_test.go b/pkg/composer/composer_test.go index 3bb0aea84..e349a0322 100644 --- a/pkg/composer/composer_test.go +++ b/pkg/composer/composer_test.go @@ -1,10 +1,16 @@ package composer import ( + "fmt" "os" "path/filepath" + "strings" "testing" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/composer/artifact" + "github.com/windsorcli/cli/pkg/composer/blueprint" + "github.com/windsorcli/cli/pkg/composer/terraform" "github.com/windsorcli/cli/pkg/runtime" "github.com/windsorcli/cli/pkg/runtime/config" "github.com/windsorcli/cli/pkg/runtime/shell" @@ -14,22 +20,36 @@ import ( // Test Setup // ============================================================================= -// setupComposerMocks creates mock components for testing the Composer -func setupComposerMocks(t *testing.T) *Mocks { +// ComposerTestMocks contains all the mock dependencies for testing the Composer +type ComposerTestMocks struct { + ConfigHandler config.ConfigHandler + Shell shell.Shell + ArtifactBuilder *artifact.MockArtifact + BlueprintHandler *blueprint.MockBlueprintHandler + TerraformResolver *terraform.MockModuleResolver + Runtime *runtime.Runtime +} + +// setupComposerMocks creates mock components for testing the Composer with optional overrides +func setupComposerMocks(t *testing.T, opts ...func(*ComposerTestMocks)) *ComposerTestMocks { t.Helper() // Create temporary directory for test tmpDir := t.TempDir() configHandler := config.NewMockConfigHandler() - // Set up GetConfigRoot to return temp directory configHandler.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + configHandler.GetBoolFunc = func(key string, defaultValue ...bool) bool { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return false + } - shell := shell.NewMockShell() - // Set up GetProjectRoot to return temp directory - shell.GetProjectRootFunc = func() (string, error) { + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } @@ -40,37 +60,55 @@ func setupComposerMocks(t *testing.T) *Mocks { ConfigRoot: filepath.Join(tmpDir, "contexts", "test-context"), TemplateRoot: filepath.Join(tmpDir, "contexts", "_template"), ConfigHandler: configHandler, - Shell: shell, + Shell: mockShell, } - return &Mocks{ - ConfigHandler: configHandler, - Shell: shell, - Runtime: rt, + // Create default mocks + mocks := &ComposerTestMocks{ + ConfigHandler: configHandler, + Shell: mockShell, + ArtifactBuilder: artifact.NewMockArtifact(), + BlueprintHandler: blueprint.NewMockBlueprintHandler(), + TerraformResolver: terraform.NewMockModuleResolver(), + Runtime: rt, + } + + // Apply any overrides + for _, opt := range opts { + opt(mocks) } + + return mocks } -// Mocks contains all the mock dependencies for testing -type Mocks struct { - ConfigHandler config.ConfigHandler - Shell shell.Shell - Runtime *runtime.Runtime +// createComposerWithMocks creates a Composer instance using the provided mocks +func createComposerWithMocks(mocks *ComposerTestMocks) *Composer { + overrideComposer := &Composer{ + ArtifactBuilder: mocks.ArtifactBuilder, + BlueprintHandler: mocks.BlueprintHandler, + TerraformResolver: mocks.TerraformResolver, + } + return NewComposer(mocks.Runtime, overrideComposer) } // ============================================================================= // Test Constructor // ============================================================================= -func TestNewComposer(t *testing.T) { +func TestComposer_NewComposer(t *testing.T) { t.Run("CreatesComposerWithDependencies", func(t *testing.T) { + // Given mocks mocks := setupComposerMocks(t) + // When creating a new composer composer := NewComposer(mocks.Runtime) + // Then composer should be created if composer == nil { t.Fatal("Expected Composer to be created") } + // And runtime dependencies should be set if composer.Runtime.Shell != mocks.Shell { t.Error("Expected shell to be set") } @@ -79,6 +117,7 @@ func TestNewComposer(t *testing.T) { t.Error("Expected config handler to be set") } + // And all resource dependencies should be initialized if composer.ArtifactBuilder == nil { t.Error("Expected artifact builder to be initialized") } @@ -91,24 +130,171 @@ func TestNewComposer(t *testing.T) { t.Error("Expected terraform resolver to be initialized") } }) -} -func TestCreateComposer(t *testing.T) { - t.Run("CreatesComposerWithDependencies", func(t *testing.T) { + t.Run("UsesOverrideArtifactBuilder", func(t *testing.T) { + // Given mocks with override artifact builder + mocks := setupComposerMocks(t) + customArtifactBuilder := artifact.NewMockArtifact() + + // When creating composer with override + overrideComposer := &Composer{ + ArtifactBuilder: customArtifactBuilder, + } + composer := NewComposer(mocks.Runtime, overrideComposer) + + // Then composer should use the override + if composer == nil { + t.Fatal("Expected Composer to be created") + } + + if composer.ArtifactBuilder != customArtifactBuilder { + t.Error("Expected override artifact builder to be used") + } + + // And other dependencies should still be initialized + if composer.BlueprintHandler == nil { + t.Error("Expected blueprint handler to be initialized") + } + + if composer.TerraformResolver == nil { + t.Error("Expected terraform resolver to be initialized") + } + }) + + t.Run("UsesOverrideBlueprintHandler", func(t *testing.T) { + // Given mocks with override blueprint handler + mocks := setupComposerMocks(t) + customBlueprintHandler := blueprint.NewMockBlueprintHandler() + + // When creating composer with override + overrideComposer := &Composer{ + BlueprintHandler: customBlueprintHandler, + } + composer := NewComposer(mocks.Runtime, overrideComposer) + + // Then composer should use the override + if composer == nil { + t.Fatal("Expected Composer to be created") + } + + if composer.BlueprintHandler != customBlueprintHandler { + t.Error("Expected override blueprint handler to be used") + } + + // And other dependencies should still be initialized + if composer.ArtifactBuilder == nil { + t.Error("Expected artifact builder to be initialized") + } + + if composer.TerraformResolver == nil { + t.Error("Expected terraform resolver to be initialized") + } + }) + + t.Run("UsesOverrideTerraformResolver", func(t *testing.T) { + // Given mocks with override terraform resolver + mocks := setupComposerMocks(t) + customTerraformResolver := terraform.NewMockModuleResolver() + + // When creating composer with override + overrideComposer := &Composer{ + TerraformResolver: customTerraformResolver, + } + composer := NewComposer(mocks.Runtime, overrideComposer) + + // Then composer should use the override + if composer == nil { + t.Fatal("Expected Composer to be created") + } + + if composer.TerraformResolver != customTerraformResolver { + t.Error("Expected override terraform resolver to be used") + } + + // And other dependencies should still be initialized + if composer.ArtifactBuilder == nil { + t.Error("Expected artifact builder to be initialized") + } + + if composer.BlueprintHandler == nil { + t.Error("Expected blueprint handler to be initialized") + } + }) + + t.Run("UsesPartialOverrides", func(t *testing.T) { + // Given mocks with partial overrides mocks := setupComposerMocks(t) + customArtifactBuilder := artifact.NewMockArtifact() + customTerraformResolver := terraform.NewMockModuleResolver() + + // When creating composer with partial overrides + overrideComposer := &Composer{ + ArtifactBuilder: customArtifactBuilder, + TerraformResolver: customTerraformResolver, + } + composer := NewComposer(mocks.Runtime, overrideComposer) + + // Then composer should use overrides where provided + if composer == nil { + t.Fatal("Expected Composer to be created") + } + + if composer.ArtifactBuilder != customArtifactBuilder { + t.Error("Expected override artifact builder to be used") + } + + if composer.TerraformResolver != customTerraformResolver { + t.Error("Expected override terraform resolver to be used") + } + + // And blueprint handler should be initialized (not overridden) + if composer.BlueprintHandler == nil { + t.Error("Expected blueprint handler to be initialized") + } + }) + t.Run("HandlesEmptyProjectRoot", func(t *testing.T) { + // Given a runtime with empty ProjectRoot + mocks := setupComposerMocks(t, func(m *ComposerTestMocks) { + m.Runtime.ProjectRoot = "" + }) + + // When creating composer composer := NewComposer(mocks.Runtime) + // Then composer should still be created (NewBlueprintHandler doesn't validate ProjectRoot) + if composer == nil { + t.Error("Expected Composer to be created even with empty ProjectRoot") + } + + // And BlueprintHandler should be initialized + if composer.BlueprintHandler == nil { + t.Error("Expected BlueprintHandler to be initialized") + } + }) + + t.Run("IgnoresNilOverride", func(t *testing.T) { + // Given mocks + mocks := setupComposerMocks(t) + + // When creating composer with nil override + composer := NewComposer(mocks.Runtime, nil) + + // Then composer should be created with defaults if composer == nil { t.Fatal("Expected Composer to be created") } - if composer.Runtime.ConfigHandler != mocks.ConfigHandler { - t.Error("Expected config handler to be set") + if composer.ArtifactBuilder == nil { + t.Error("Expected artifact builder to be initialized") } - if composer.Runtime.Shell != mocks.Shell { - t.Error("Expected shell to be set") + if composer.BlueprintHandler == nil { + t.Error("Expected blueprint handler to be initialized") + } + + if composer.TerraformResolver == nil { + t.Error("Expected terraform resolver to be initialized") } }) } @@ -117,82 +303,1056 @@ func TestCreateComposer(t *testing.T) { // Test Public Methods // ============================================================================= +func TestComposer_Bundle(t *testing.T) { + t.Run("SuccessWithExplicitPath", func(t *testing.T) { + // Given mocks with artifact builder + mocks := setupComposerMocks(t) + expectedPath := "/tmp/bundle.tar.gz" + mocks.ArtifactBuilder.WriteFunc = func(outputPath string, tag string) (string, error) { + return expectedPath, nil + } + composer := createComposerWithMocks(mocks) + + // When bundling with explicit path + result, err := composer.Bundle("/tmp/bundle.tar.gz", "v1.0.0") + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And result should match expected path + if result != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, result) + } + }) + + t.Run("SuccessWithTag", func(t *testing.T) { + // Given mocks with artifact builder + mocks := setupComposerMocks(t) + expectedPath := "/tmp/bundle-v1.0.0.tar.gz" + mocks.ArtifactBuilder.WriteFunc = func(outputPath string, tag string) (string, error) { + if tag != "v1.0.0" { + t.Errorf("Expected tag v1.0.0, got %s", tag) + } + return expectedPath, nil + } + composer := createComposerWithMocks(mocks) + + // When bundling with tag + result, err := composer.Bundle("/tmp/bundle.tar.gz", "v1.0.0") + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And result should match expected path + if result != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, result) + } + }) + + t.Run("SuccessWithRelativePath", func(t *testing.T) { + // Given mocks with artifact builder + mocks := setupComposerMocks(t) + expectedPath := "bundle.tar.gz" + mocks.ArtifactBuilder.WriteFunc = func(outputPath string, tag string) (string, error) { + return expectedPath, nil + } + composer := createComposerWithMocks(mocks) + + // When bundling with relative path + result, err := composer.Bundle("bundle.tar.gz", "") + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And result should match expected path + if result != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, result) + } + }) + + t.Run("ErrorFromArtifactBuilderWrite", func(t *testing.T) { + // Given mocks with artifact builder that returns error + mocks := setupComposerMocks(t) + expectedError := "write failed" + mocks.ArtifactBuilder.WriteFunc = func(outputPath string, tag string) (string, error) { + return "", fmt.Errorf("%s", expectedError) + } + composer := createComposerWithMocks(mocks) + + // When bundling + result, err := composer.Bundle("/tmp/bundle.tar.gz", "") + + // Then error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got: %v", expectedError, err) + } + + // And result should be empty + if result != "" { + t.Errorf("Expected empty result on error, got: %s", result) + } + }) +} + func TestComposer_Push(t *testing.T) { - t.Run("HandlesPushSuccessfully", func(t *testing.T) { + t.Run("SuccessWithFullURL", func(t *testing.T) { + // Given mocks with artifact builder mocks := setupComposerMocks(t) - composer := NewComposer(mocks.Runtime) + registryBase := "ghcr.io" + repoName := "test/repo" + tag := "latest" + mocks.ArtifactBuilder.BundleFunc = func() error { + return nil + } + mocks.ArtifactBuilder.PushFunc = func(base string, repo string, tagValue string) error { + if base != registryBase { + t.Errorf("Expected registry base %s, got %s", registryBase, base) + } + if repo != repoName { + t.Errorf("Expected repo name %s, got %s", repoName, repo) + } + if tagValue != tag { + t.Errorf("Expected tag %s, got %s", tag, tagValue) + } + return nil + } + composer := createComposerWithMocks(mocks) + + // When pushing with full URL + result, err := composer.Push("ghcr.io/test/repo:latest") - // This test would need proper mocking of the artifact builder - // For now, we'll just test that the method exists and handles errors - _, err := composer.Push("ghcr.io/test/repo:latest") - // We expect an error here because we don't have proper mocks set up + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And result should be correct URL + expectedURL := "ghcr.io/test/repo:latest" + if result != expectedURL { + t.Errorf("Expected URL %s, got %s", expectedURL, result) + } + }) + + t.Run("SuccessWithOCIPrefix", func(t *testing.T) { + // Given mocks with artifact builder + mocks := setupComposerMocks(t) + mocks.ArtifactBuilder.BundleFunc = func() error { + return nil + } + mocks.ArtifactBuilder.PushFunc = func(base string, repo string, tag string) error { + return nil + } + composer := createComposerWithMocks(mocks) + + // When pushing with OCI prefix + result, err := composer.Push("oci://ghcr.io/test/repo:v1.0.0") + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And result should be correct URL (without oci:// prefix) + expectedURL := "ghcr.io/test/repo:v1.0.0" + if result != expectedURL { + t.Errorf("Expected URL %s, got %s", expectedURL, result) + } + }) + + t.Run("SuccessWithoutTag", func(t *testing.T) { + // Given mocks with artifact builder + mocks := setupComposerMocks(t) + mocks.ArtifactBuilder.BundleFunc = func() error { + return nil + } + mocks.ArtifactBuilder.PushFunc = func(base string, repo string, tag string) error { + if tag != "" { + t.Errorf("Expected empty tag, got %s", tag) + } + return nil + } + composer := createComposerWithMocks(mocks) + + // When pushing without tag + result, err := composer.Push("ghcr.io/test/repo") + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And result should be URL without tag + expectedURL := "ghcr.io/test/repo" + if result != expectedURL { + t.Errorf("Expected URL %s, got %s", expectedURL, result) + } + }) + + t.Run("ErrorFromParseRegistryURL", func(t *testing.T) { + // Given mocks + mocks := setupComposerMocks(t) + composer := createComposerWithMocks(mocks) + + // When pushing with invalid URL + result, err := composer.Push("invalid-url") + + // Then error should be returned if err == nil { - t.Error("Expected error due to missing mocks, but got nil") + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), "failed to parse registry URL") { + t.Errorf("Expected error about parsing registry URL, got: %v", err) + } + + // And result should be empty + if result != "" { + t.Errorf("Expected empty result on error, got: %s", result) + } + }) + + t.Run("ErrorFromBundle", func(t *testing.T) { + // Given mocks with artifact builder that fails on bundle + mocks := setupComposerMocks(t) + expectedError := "bundle failed" + mocks.ArtifactBuilder.BundleFunc = func() error { + return fmt.Errorf("%s", expectedError) + } + composer := createComposerWithMocks(mocks) + + // When pushing + result, err := composer.Push("ghcr.io/test/repo:latest") + + // Then error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got: %v", expectedError, err) + } + + // And result should be empty + if result != "" { + t.Errorf("Expected empty result on error, got: %s", result) + } + }) + + t.Run("ErrorFromPush", func(t *testing.T) { + // Given mocks with artifact builder that fails on push + mocks := setupComposerMocks(t) + expectedError := "push failed" + mocks.ArtifactBuilder.BundleFunc = func() error { + return nil + } + mocks.ArtifactBuilder.PushFunc = func(base string, repo string, tag string) error { + return fmt.Errorf("%s", expectedError) + } + composer := createComposerWithMocks(mocks) + + // When pushing + result, err := composer.Push("ghcr.io/test/repo:latest") + + // Then error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got: %v", expectedError, err) + } + + // And result should be empty + if result != "" { + t.Errorf("Expected empty result on error, got: %s", result) } }) } func TestComposer_Generate(t *testing.T) { - t.Run("HandlesGenerateSuccessfully", func(t *testing.T) { + t.Run("SuccessFullFlow", func(t *testing.T) { + // Given mocks with all handlers succeeding mocks := setupComposerMocks(t) - // Create TemplateRoot directory so LoadBlueprint checks for empty template data - if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { - t.Fatalf("Failed to create TemplateRoot: %v", err) + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil } - composer := NewComposer(mocks.Runtime) + mocks.BlueprintHandler.WriteFunc = func(overwrite ...bool) error { + return nil + } + mocks.TerraformResolver.ProcessModulesFunc = func() error { + return nil + } + composer := createComposerWithMocks(mocks) - // Generate will fail because blueprint.yaml doesn't exist in ConfigRoot and template is empty + // When generating err := composer.Generate() - // We expect an error because blueprint.yaml doesn't exist in the test setup - if err == nil { - t.Error("Expected error due to missing blueprint.yaml, but got nil") + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) } }) - t.Run("HandlesGenerateWithOverwrite", func(t *testing.T) { + t.Run("SuccessWithOverwriteTrue", func(t *testing.T) { + // Given mocks with all handlers succeeding mocks := setupComposerMocks(t) - // Create TemplateRoot directory so LoadBlueprint checks for empty template data - if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { - t.Fatalf("Failed to create TemplateRoot: %v", err) + overwriteCalled := false + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil } - composer := NewComposer(mocks.Runtime) + mocks.BlueprintHandler.WriteFunc = func(overwrite ...bool) error { + if len(overwrite) > 0 && overwrite[0] { + overwriteCalled = true + } + return nil + } + mocks.TerraformResolver.ProcessModulesFunc = func() error { + return nil + } + composer := createComposerWithMocks(mocks) - // Generate will fail because blueprint.yaml doesn't exist in ConfigRoot and template is empty + // When generating with overwrite true err := composer.Generate(true) - // We expect an error because blueprint.yaml doesn't exist in the test setup + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And overwrite should be passed to Write + if !overwriteCalled { + t.Error("Expected overwrite to be passed to Write") + } + }) + + t.Run("SuccessWithOverwriteFalse", func(t *testing.T) { + // Given mocks with all handlers succeeding + mocks := setupComposerMocks(t) + overwriteValue := true + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil + } + mocks.BlueprintHandler.WriteFunc = func(overwrite ...bool) error { + if len(overwrite) > 0 { + overwriteValue = overwrite[0] + } + return nil + } + mocks.TerraformResolver.ProcessModulesFunc = func() error { + return nil + } + composer := createComposerWithMocks(mocks) + + // When generating with overwrite false + err := composer.Generate(false) + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And overwrite should be false + if overwriteValue { + t.Error("Expected overwrite to be false") + } + }) + + t.Run("SuccessWithTerraformEnabled", func(t *testing.T) { + // Given mocks with terraform enabled + mocks := setupComposerMocks(t) + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetBoolFunc = func(key string, defaultValue ...bool) bool { + if key == "terraform.enabled" { + return true + } + if len(defaultValue) > 0 { + return defaultValue[0] + } + return false + } + } + generateTfvarsCalled := false + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil + } + mocks.BlueprintHandler.WriteFunc = func(overwrite ...bool) error { + return nil + } + mocks.TerraformResolver.ProcessModulesFunc = func() error { + return nil + } + mocks.TerraformResolver.GenerateTfvarsFunc = func(overwrite bool) error { + generateTfvarsCalled = true + return nil + } + composer := createComposerWithMocks(mocks) + + // When generating + err := composer.Generate() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And GenerateTfvars should be called + if !generateTfvarsCalled { + t.Error("Expected GenerateTfvars to be called when terraform is enabled") + } + }) + + t.Run("SuccessWithTerraformDisabled", func(t *testing.T) { + // Given mocks with terraform disabled + mocks := setupComposerMocks(t) + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetBoolFunc = func(key string, defaultValue ...bool) bool { + if key == "terraform.enabled" { + return false + } + if len(defaultValue) > 0 { + return defaultValue[0] + } + return false + } + } + generateTfvarsCalled := false + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil + } + mocks.BlueprintHandler.WriteFunc = func(overwrite ...bool) error { + return nil + } + mocks.TerraformResolver.ProcessModulesFunc = func() error { + return nil + } + mocks.TerraformResolver.GenerateTfvarsFunc = func(overwrite bool) error { + generateTfvarsCalled = true + return nil + } + composer := createComposerWithMocks(mocks) + + // When generating + err := composer.Generate() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And GenerateTfvars should not be called + if generateTfvarsCalled { + t.Error("Expected GenerateTfvars not to be called when terraform is disabled") + } + }) + + t.Run("ErrorFromLoadBlueprint", func(t *testing.T) { + // Given mocks with LoadBlueprint failing + mocks := setupComposerMocks(t) + expectedError := "load blueprint failed" + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return fmt.Errorf("%s", expectedError) + } + composer := createComposerWithMocks(mocks) + + // When generating + err := composer.Generate() + + // Then error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got: %v", expectedError, err) + } + }) + + t.Run("ErrorFromWrite", func(t *testing.T) { + // Given mocks with Write failing + mocks := setupComposerMocks(t) + expectedError := "write failed" + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil + } + mocks.BlueprintHandler.WriteFunc = func(overwrite ...bool) error { + return fmt.Errorf("%s", expectedError) + } + composer := createComposerWithMocks(mocks) + + // When generating + err := composer.Generate() + + // Then error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got: %v", expectedError, err) + } + }) + + t.Run("ErrorFromProcessModules", func(t *testing.T) { + // Given mocks with ProcessModules failing + mocks := setupComposerMocks(t) + expectedError := "process modules failed" + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil + } + mocks.BlueprintHandler.WriteFunc = func(overwrite ...bool) error { + return nil + } + mocks.TerraformResolver.ProcessModulesFunc = func() error { + return fmt.Errorf("%s", expectedError) + } + composer := createComposerWithMocks(mocks) + + // When generating + err := composer.Generate() + + // Then error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got: %v", expectedError, err) + } + }) + + t.Run("ErrorFromGenerateGitignore", func(t *testing.T) { + // Given mocks with generateGitignore failing (simulated via file system error) + mocks := setupComposerMocks(t) + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil + } + mocks.BlueprintHandler.WriteFunc = func(overwrite ...bool) error { + return nil + } + mocks.TerraformResolver.ProcessModulesFunc = func() error { + return nil + } + mocks.Runtime.ProjectRoot = "/nonexistent/path/that/cannot/be/written" + composer := createComposerWithMocks(mocks) + + // When generating + err := composer.Generate() + + // Then error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), "failed to generate .gitignore") { + t.Errorf("Expected error about generating .gitignore, got: %v", err) + } + }) + + t.Run("ErrorFromGenerateTfvars", func(t *testing.T) { + // Given mocks with GenerateTfvars failing + mocks := setupComposerMocks(t) + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetBoolFunc = func(key string, defaultValue ...bool) bool { + if key == "terraform.enabled" { + return true + } + if len(defaultValue) > 0 { + return defaultValue[0] + } + return false + } + } + expectedError := "generate tfvars failed" + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil + } + mocks.BlueprintHandler.WriteFunc = func(overwrite ...bool) error { + return nil + } + mocks.TerraformResolver.ProcessModulesFunc = func() error { + return nil + } + mocks.TerraformResolver.GenerateTfvarsFunc = func(overwrite bool) error { + return fmt.Errorf("%s", expectedError) + } + composer := createComposerWithMocks(mocks) + + // When generating + err := composer.Generate() + + // Then error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got: %v", expectedError, err) + } + }) +} + +func TestComposer_GenerateBlueprint(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given mocks with blueprint handler + mocks := setupComposerMocks(t) + expectedBlueprint := &blueprintv1alpha1.Blueprint{ + Kind: "Blueprint", + ApiVersion: "v1alpha1", + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + Description: "Test blueprint", + }, + } + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return nil + } + mocks.BlueprintHandler.GenerateFunc = func() *blueprintv1alpha1.Blueprint { + return expectedBlueprint + } + composer := createComposerWithMocks(mocks) + + // When generating blueprint + result, err := composer.GenerateBlueprint() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And result should match expected blueprint + if result == nil { + t.Fatal("Expected non-nil blueprint") + } + + if result.Metadata.Name != expectedBlueprint.Metadata.Name { + t.Errorf("Expected blueprint name %s, got %s", expectedBlueprint.Metadata.Name, result.Metadata.Name) + } + }) + + t.Run("ErrorFromLoadBlueprint", func(t *testing.T) { + // Given mocks with LoadBlueprint failing + mocks := setupComposerMocks(t) + expectedError := "load blueprint failed" + mocks.BlueprintHandler.LoadBlueprintFunc = func() error { + return fmt.Errorf("%s", expectedError) + } + composer := createComposerWithMocks(mocks) + + // When generating blueprint + result, err := composer.GenerateBlueprint() + + // Then error should be returned if err == nil { - t.Error("Expected error due to missing blueprint.yaml, but got nil") + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got: %v", expectedError, err) + } + + // And result should be nil + if result != nil { + t.Error("Expected nil result on error") } }) } // ============================================================================= -// Test Runtime +// Test Private Methods // ============================================================================= -func TestRuntime(t *testing.T) { - t.Run("CreatesRuntime", func(t *testing.T) { - rt := &runtime.Runtime{ - ContextName: "test-context", - ProjectRoot: "/test/project", - ConfigRoot: "/test/project/contexts/test-context", - TemplateRoot: "/test/project/contexts/_template", +func TestComposer_generateGitignore(t *testing.T) { + t.Run("CreatesNewFile", func(t *testing.T) { + // Given a composer with temporary project root + mocks := setupComposerMocks(t) + composer := createComposerWithMocks(mocks) + + // When generating gitignore + err := composer.generateGitignore() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) } - if rt.ContextName != "test-context" { - t.Errorf("Expected context name 'test-context', got: %s", rt.ContextName) + // And .gitignore file should be created + gitignorePath := filepath.Join(mocks.Runtime.ProjectRoot, ".gitignore") + content, readErr := os.ReadFile(gitignorePath) + if readErr != nil { + t.Fatalf("Expected .gitignore to be created, got error: %v", readErr) } - if rt.ProjectRoot != "/test/project" { - t.Errorf("Expected project root '/test/project', got: %s", rt.ProjectRoot) + // And file should contain Windsor entries + contentStr := string(content) + if !strings.Contains(contentStr, "# managed by windsor cli") { + t.Error("Expected .gitignore to contain Windsor header") } - if rt.ConfigRoot != "/test/project/contexts/test-context" { - t.Errorf("Expected config root '/test/project/contexts/test-context', got: %s", rt.ConfigRoot) + if !strings.Contains(contentStr, ".windsor/") { + t.Error("Expected .gitignore to contain .windsor/ entry") } + }) + + t.Run("UpdatesExistingFile", func(t *testing.T) { + // Given a composer with existing .gitignore + mocks := setupComposerMocks(t) + existingContent := "existing-entry\n" + gitignorePath := filepath.Join(mocks.Runtime.ProjectRoot, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to create existing .gitignore: %v", err) + } + composer := createComposerWithMocks(mocks) + + // When generating gitignore + err := composer.generateGitignore() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And file should contain both existing and new entries + content, readErr := os.ReadFile(gitignorePath) + if readErr != nil { + t.Fatalf("Failed to read .gitignore: %v", readErr) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "existing-entry") { + t.Error("Expected .gitignore to preserve existing entry") + } + + if !strings.Contains(contentStr, ".windsor/") { + t.Error("Expected .gitignore to contain new Windsor entry") + } + }) + + t.Run("PreservesUserEntries", func(t *testing.T) { + // Given a composer with existing .gitignore with user entries + mocks := setupComposerMocks(t) + existingContent := "user-entry-1\nuser-entry-2\n" + gitignorePath := filepath.Join(mocks.Runtime.ProjectRoot, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to create existing .gitignore: %v", err) + } + composer := createComposerWithMocks(mocks) + + // When generating gitignore + err := composer.generateGitignore() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And file should contain all user entries + content, readErr := os.ReadFile(gitignorePath) + if readErr != nil { + t.Fatalf("Failed to read .gitignore: %v", readErr) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "user-entry-1") { + t.Error("Expected .gitignore to preserve user-entry-1") + } + + if !strings.Contains(contentStr, "user-entry-2") { + t.Error("Expected .gitignore to preserve user-entry-2") + } + }) + + t.Run("HandlesCommentedEntries", func(t *testing.T) { + // Given a composer with existing .gitignore with commented Windsor entry + mocks := setupComposerMocks(t) + existingContent := "# .windsor/\nuser-entry\n" + gitignorePath := filepath.Join(mocks.Runtime.ProjectRoot, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to create existing .gitignore: %v", err) + } + composer := createComposerWithMocks(mocks) + + // When generating gitignore + err := composer.generateGitignore() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And file should not duplicate the entry + content, readErr := os.ReadFile(gitignorePath) + if readErr != nil { + t.Fatalf("Failed to read .gitignore: %v", readErr) + } + + contentStr := string(content) + occurrences := strings.Count(contentStr, ".windsor/") + if occurrences != 1 { + t.Errorf("Expected .windsor/ to appear once, got %d occurrences", occurrences) + } + }) + + t.Run("AddsMissingEntries", func(t *testing.T) { + // Given a composer with existing .gitignore missing some Windsor entries + mocks := setupComposerMocks(t) + existingContent := "# managed by windsor cli\n.windsor/\n" + gitignorePath := filepath.Join(mocks.Runtime.ProjectRoot, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to create existing .gitignore: %v", err) + } + composer := createComposerWithMocks(mocks) + + // When generating gitignore + err := composer.generateGitignore() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And file should contain all Windsor entries + content, readErr := os.ReadFile(gitignorePath) + if readErr != nil { + t.Fatalf("Failed to read .gitignore: %v", readErr) + } + + contentStr := string(content) + requiredEntries := []string{".windsor/", ".volumes/", "terraform/**/backend_override.tf"} + for _, entry := range requiredEntries { + if !strings.Contains(contentStr, entry) { + t.Errorf("Expected .gitignore to contain %s", entry) + } + } + }) + + t.Run("HandlesTrailingNewline", func(t *testing.T) { + // Given a composer with existing .gitignore without trailing newline + mocks := setupComposerMocks(t) + existingContent := "user-entry" + gitignorePath := filepath.Join(mocks.Runtime.ProjectRoot, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to create existing .gitignore: %v", err) + } + composer := createComposerWithMocks(mocks) + + // When generating gitignore + err := composer.generateGitignore() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And file should end with newline + content, readErr := os.ReadFile(gitignorePath) + if readErr != nil { + t.Fatalf("Failed to read .gitignore: %v", readErr) + } + + if len(content) > 0 && content[len(content)-1] != '\n' { + t.Error("Expected .gitignore to end with newline") + } + }) + + t.Run("ErrorFromWriteFile", func(t *testing.T) { + // Given a composer with existing .gitignore that can be read + mocks := setupComposerMocks(t) + existingContent := "existing-entry\n" + gitignorePath := filepath.Join(mocks.Runtime.ProjectRoot, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to create existing .gitignore: %v", err) + } + composer := createComposerWithMocks(mocks) + + // Make the .gitignore file itself read-only to simulate write failure + if err := os.Chmod(gitignorePath, 0444); err != nil { + t.Fatalf("Failed to make .gitignore read-only: %v", err) + } + defer func() { + os.Chmod(gitignorePath, 0644) + }() + + // When generating gitignore + err := composer.generateGitignore() + + // Then error should be returned (write will fail due to read-only file) + if err == nil { + t.Fatal("Expected error, got nil") + } + + if !strings.Contains(err.Error(), "failed to write to .gitignore") && !strings.Contains(err.Error(), "permission denied") { + t.Errorf("Expected error about writing .gitignore or permission denied, got: %v", err) + } + }) + + t.Run("HandlesEmptyExistingFile", func(t *testing.T) { + // Given a composer with empty existing .gitignore + mocks := setupComposerMocks(t) + gitignorePath := filepath.Join(mocks.Runtime.ProjectRoot, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(""), 0644); err != nil { + t.Fatalf("Failed to create empty .gitignore: %v", err) + } + composer := createComposerWithMocks(mocks) + + // When generating gitignore + err := composer.generateGitignore() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And file should contain Windsor entries + content, readErr := os.ReadFile(gitignorePath) + if readErr != nil { + t.Fatalf("Failed to read .gitignore: %v", readErr) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "# managed by windsor cli") { + t.Error("Expected .gitignore to contain Windsor header") + } + }) + + t.Run("HandlesFileWithOnlyComments", func(t *testing.T) { + // Given a composer with existing .gitignore with only comments + mocks := setupComposerMocks(t) + existingContent := "# comment 1\n# comment 2\n" + gitignorePath := filepath.Join(mocks.Runtime.ProjectRoot, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to create existing .gitignore: %v", err) + } + composer := createComposerWithMocks(mocks) + + // When generating gitignore + err := composer.generateGitignore() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And file should contain both comments and Windsor entries + content, readErr := os.ReadFile(gitignorePath) + if readErr != nil { + t.Fatalf("Failed to read .gitignore: %v", readErr) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "# comment 1") { + t.Error("Expected .gitignore to preserve comment 1") + } + + if !strings.Contains(contentStr, ".windsor/") { + t.Error("Expected .gitignore to contain Windsor entry") + } + }) +} + +func TestComposer_normalizeGitignoreComment(t *testing.T) { + t.Run("SimpleCommentFormat", func(t *testing.T) { + // Given a simple comment line + line := "# .windsor/" + + // When normalizing + result := normalizeGitignoreComment(line) + + // Then result should be the uncommented entry + expected := ".windsor/" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } + }) + + t.Run("MultipleHashSymbols", func(t *testing.T) { + // Given a comment line with multiple hash symbols + line := "## .windsor/" + + // When normalizing + result := normalizeGitignoreComment(line) + + // Then result should be the uncommented entry + expected := ".windsor/" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } + }) + + t.Run("CommentWithLeadingWhitespace", func(t *testing.T) { + // Given a comment line with leading whitespace + line := " # .windsor/" + + // When normalizing + result := normalizeGitignoreComment(line) + + // Then result should be the uncommented entry + expected := ".windsor/" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } + }) + + t.Run("CommentWithTrailingWhitespace", func(t *testing.T) { + // Given a comment line with trailing whitespace + line := "# .windsor/ " + + // When normalizing + result := normalizeGitignoreComment(line) + + // Then result should be the uncommented entry without trailing whitespace + expected := ".windsor/" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } + }) + + t.Run("NonCommentLine", func(t *testing.T) { + // Given a non-comment line + line := ".windsor/" + + // When normalizing + result := normalizeGitignoreComment(line) + + // Then result should be empty string + if result != "" { + t.Errorf("Expected empty string, got %s", result) + } + }) + + t.Run("OnlyHashSymbol", func(t *testing.T) { + // Given a line with only hash symbol + line := "#" + + // When normalizing + result := normalizeGitignoreComment(line) + + // Then result should be empty string + if result != "" { + t.Errorf("Expected empty string, got %s", result) + } + }) + + t.Run("CommentWithMultipleSpaces", func(t *testing.T) { + // Given a comment line with multiple spaces + line := "# .windsor/ " + + // When normalizing + result := normalizeGitignoreComment(line) - if rt.TemplateRoot != "/test/project/contexts/_template" { - t.Errorf("Expected template root '/test/project/contexts/_template', got: %s", rt.TemplateRoot) + // Then result should be the uncommented entry + expected := ".windsor/" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) } }) } From d23a54ecdf4b8d28a5f65784d35e0e6bccb927b0 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Sat, 15 Nov 2025 10:35:02 -0500 Subject: [PATCH 2/8] refactor artifacts tests Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- pkg/composer/artifact/artifact.go | 229 +- .../artifact/artifact_helpers_test.go | 645 +++ .../artifact/artifact_private_test.go | 1792 ++++++++ ...tifact_test.go => artifact_public_test.go} | 3844 +++++------------ 4 files changed, 3541 insertions(+), 2969 deletions(-) create mode 100644 pkg/composer/artifact/artifact_helpers_test.go create mode 100644 pkg/composer/artifact/artifact_private_test.go rename pkg/composer/artifact/{artifact_test.go => artifact_public_test.go} (55%) diff --git a/pkg/composer/artifact/artifact.go b/pkg/composer/artifact/artifact.go index 1cb8ce03c..b9bfe3065 100644 --- a/pkg/composer/artifact/artifact.go +++ b/pkg/composer/artifact/artifact.go @@ -30,6 +30,19 @@ import ( // step in the bundling pipeline, creating self-contained artifacts that include all bundled // dependencies and metadata for distribution. +// ============================================================================= +// Interfaces +// ============================================================================= + +// Artifact defines the interface for artifact creation operations +type Artifact interface { + Bundle() error + Write(outputPath string, tag string) (string, error) + Push(registryBase string, repoName string, tag string) error + Pull(ociRefs []string) (map[string][]byte, error) + GetTemplateData(ociRef string) (map[string][]byte, error) +} + // ============================================================================= // Types // ============================================================================= @@ -64,12 +77,9 @@ type BuilderInfo struct { // OCIArtifactInfo contains information about the OCI artifact source for blueprint data type OCIArtifactInfo struct { - // Name is the name of the OCI artifact Name string - // URL is the full OCI URL of the artifact - URL string - // Tag is the tag/version of the OCI artifact - Tag string + URL string + Tag string } // BlueprintMetadataInput represents the input metadata from contexts/_template/metadata.yaml @@ -84,23 +94,6 @@ type BlueprintMetadataInput struct { CliVersion string `yaml:"cliVersion,omitempty"` } -// ============================================================================= -// Interfaces -// ============================================================================= - -// Artifact defines the interface for artifact creation operations -type Artifact interface { - Bundle() error - Write(outputPath string, tag string) (string, error) - Push(registryBase string, repoName string, tag string) error - Pull(ociRefs []string) (map[string][]byte, error) - GetTemplateData(ociRef string) (map[string][]byte, error) -} - -// ============================================================================= -// ArtifactBuilder Implementation -// ============================================================================= - // FileInfo holds file content and permission information type FileInfo struct { Content []byte @@ -536,6 +529,100 @@ func ParseOCIReference(ociRef string) (*OCIArtifactInfo, error) { }, nil } +// ParseRegistryURL parses a registry URL string into its components. +// It handles formats like "registry.com/repo:tag", "registry.com/repo", or "oci://registry.com/repo:tag". +// Returns registryBase, repoName, tag, and an error if parsing fails. +func ParseRegistryURL(registryURL string) (registryBase, repoName, tag string, err error) { + arg := strings.TrimPrefix(registryURL, "oci://") + + if lastColon := strings.LastIndex(arg, ":"); lastColon > 0 && lastColon < len(arg)-1 { + tag = arg[lastColon+1:] + arg = arg[:lastColon] + } + + if firstSlash := strings.Index(arg, "/"); firstSlash >= 0 { + registryBase = arg[:firstSlash] + repoName = arg[firstSlash+1:] + } else { + return "", "", "", fmt.Errorf("invalid registry format: must include repository path (e.g., registry.com/namespace/repo)") + } + + return registryBase, repoName, tag, nil +} + +// IsAuthenticationError checks if the error is related to authentication failure. +// It examines common authentication error patterns in error messages to determine +// if the failure is due to authentication issues rather than other problems. +func IsAuthenticationError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + + authErrorPatterns := []string{ + "UNAUTHORIZED", + "unauthorized", + "authentication required", + "authentication failed", + "not authorized", + "access denied", + "login required", + "credentials required", + "401", + "403", + "unauthenticated", + "User cannot be authenticated", + "failed to push artifact", + "POST https://", + "blobs/uploads", + } + + for _, pattern := range authErrorPatterns { + if strings.Contains(errStr, pattern) { + return true + } + } + + return false +} + +// ValidateCliVersion validates that the provided CLI version satisfies the cliVersion constraint +// specified in the template metadata. If constraint is empty, validation is skipped. +// If cliVersion is empty, validation is skipped (caller cannot determine version). +// If the CLI version is "dev" or "main" or "latest", validation is skipped as these are development builds. +// Returns an error if the constraint is specified and the version does not satisfy it. +func ValidateCliVersion(cliVersion, constraint string) error { + if constraint == "" { + return nil + } + + if cliVersion == "" { + return nil + } + + if cliVersion == "dev" || cliVersion == "main" || cliVersion == "latest" { + return nil + } + + versionStr := strings.TrimPrefix(cliVersion, "v") + version, err := semver.NewVersion(versionStr) + if err != nil { + return fmt.Errorf("invalid CLI version format '%s': %w", cliVersion, err) + } + + c, err := semver.NewConstraint(constraint) + if err != nil { + return fmt.Errorf("invalid cliVersion constraint '%s': %w", constraint, err) + } + + if !c.Check(version) { + return fmt.Errorf("CLI version %s does not satisfy required constraint '%s'", cliVersion, constraint) + } + + return nil +} + // ============================================================================= // Private Methods // ============================================================================= @@ -1073,103 +1160,5 @@ func (a *ArtifactBuilder) downloadOCIArtifact(registry, repository, tag string) return data, nil } -// ============================================================================= -// Helper Functions -// ============================================================================= - -// ParseRegistryURL parses a registry URL string into its components. -// It handles formats like "registry.com/repo:tag", "registry.com/repo", or "oci://registry.com/repo:tag". -// Returns registryBase, repoName, tag, and an error if parsing fails. -func ParseRegistryURL(registryURL string) (registryBase, repoName, tag string, err error) { - arg := strings.TrimPrefix(registryURL, "oci://") - - if lastColon := strings.LastIndex(arg, ":"); lastColon > 0 && lastColon < len(arg)-1 { - tag = arg[lastColon+1:] - arg = arg[:lastColon] - } - - if firstSlash := strings.Index(arg, "/"); firstSlash >= 0 { - registryBase = arg[:firstSlash] - repoName = arg[firstSlash+1:] - } else { - return "", "", "", fmt.Errorf("invalid registry format: must include repository path (e.g., registry.com/namespace/repo)") - } - - return registryBase, repoName, tag, nil -} - -// IsAuthenticationError checks if the error is related to authentication failure. -// It examines common authentication error patterns in error messages to determine -// if the failure is due to authentication issues rather than other problems. -func IsAuthenticationError(err error) bool { - if err == nil { - return false - } - - errStr := err.Error() - - authErrorPatterns := []string{ - "UNAUTHORIZED", - "unauthorized", - "authentication required", - "authentication failed", - "not authorized", - "access denied", - "login required", - "credentials required", - "401", - "403", - "unauthenticated", - "User cannot be authenticated", - "failed to push artifact", - "POST https://", - "blobs/uploads", - } - - for _, pattern := range authErrorPatterns { - if strings.Contains(errStr, pattern) { - return true - } - } - - return false -} - -// ValidateCliVersion validates that the provided CLI version satisfies the cliVersion constraint -// specified in the template metadata. If constraint is empty, validation is skipped. -// If cliVersion is empty, validation is skipped (caller cannot determine version). -// If the CLI version is "dev" or "main" or "latest", validation is skipped as these are development builds. -// Returns an error if the constraint is specified and the version does not satisfy it. -func ValidateCliVersion(cliVersion, constraint string) error { - if constraint == "" { - return nil - } - - if cliVersion == "" { - return nil - } - - if cliVersion == "dev" || cliVersion == "main" || cliVersion == "latest" { - return nil - } - - versionStr := strings.TrimPrefix(cliVersion, "v") - version, err := semver.NewVersion(versionStr) - if err != nil { - return fmt.Errorf("invalid CLI version format '%s': %w", cliVersion, err) - } - - c, err := semver.NewConstraint(constraint) - if err != nil { - return fmt.Errorf("invalid cliVersion constraint '%s': %w", constraint, err) - } - - if !c.Check(version) { - return fmt.Errorf("CLI version %s does not satisfy required constraint '%s'", cliVersion, constraint) - } - - return nil -} - // Ensure ArtifactBuilder implements Artifact interface var _ Artifact = (*ArtifactBuilder)(nil) diff --git a/pkg/composer/artifact/artifact_helpers_test.go b/pkg/composer/artifact/artifact_helpers_test.go new file mode 100644 index 000000000..5bf12c6c0 --- /dev/null +++ b/pkg/composer/artifact/artifact_helpers_test.go @@ -0,0 +1,645 @@ +package artifact + +import ( + "fmt" + "strings" + "testing" +) + +func TestParseOCIReference(t *testing.T) { + testCases := []struct { + name string + input string + expected *OCIArtifactInfo + expectError bool + }{ + { + name: "EmptyString", + input: "", + expected: nil, + expectError: false, + }, + { + name: "FullOCIURL", + input: "oci://ghcr.io/windsorcli/core:v1.0.0", + expected: &OCIArtifactInfo{ + Name: "core", + URL: "oci://ghcr.io/windsorcli/core:v1.0.0", + Tag: "v1.0.0", + }, + expectError: false, + }, + { + name: "ShortFormat", + input: "windsorcli/core:v1.0.0", + expected: &OCIArtifactInfo{ + Name: "core", + URL: "oci://ghcr.io/windsorcli/core:v1.0.0", + Tag: "v1.0.0", + }, + expectError: false, + }, + { + name: "MissingVersion", + input: "windsorcli/core", + expected: nil, + expectError: true, + }, + { + name: "InvalidFormat", + input: "core:v1.0.0", + expected: nil, + expectError: true, + }, + { + name: "EmptyVersion", + input: "windsorcli/core:", + expected: nil, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := ParseOCIReference(tc.input) + + if tc.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if tc.expected == nil { + if result != nil { + t.Errorf("Expected nil result but got: %+v", result) + } + return + } + + if result == nil { + t.Errorf("Expected result but got nil") + return + } + + if result.Name != tc.expected.Name { + t.Errorf("Expected name %s but got %s", tc.expected.Name, result.Name) + } + + if result.URL != tc.expected.URL { + t.Errorf("Expected URL %s but got %s", tc.expected.URL, result.URL) + } + + if result.Tag != tc.expected.Tag { + t.Errorf("Expected tag %s but got %s", tc.expected.Tag, result.Tag) + } + }) + } +} + +func TestParseRegistryURL(t *testing.T) { + t.Run("ParsesRegistryURLWithTag", func(t *testing.T) { + // Given a registry URL with tag + url := "ghcr.io/windsorcli/core:v1.0.0" + + // When parsing the URL + registryBase, repoName, tag, err := ParseRegistryURL(url) + + // Then parsing should succeed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And components should be correct + if registryBase != "ghcr.io" { + t.Errorf("Expected registryBase 'ghcr.io', got '%s'", registryBase) + } + if repoName != "windsorcli/core" { + t.Errorf("Expected repoName 'windsorcli/core', got '%s'", repoName) + } + if tag != "v1.0.0" { + t.Errorf("Expected tag 'v1.0.0', got '%s'", tag) + } + }) + + t.Run("ParsesRegistryURLWithoutTag", func(t *testing.T) { + // Given a registry URL without tag + url := "docker.io/myuser/myblueprint" + + // When parsing the URL + registryBase, repoName, tag, err := ParseRegistryURL(url) + + // Then parsing should succeed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And components should be correct + if registryBase != "docker.io" { + t.Errorf("Expected registryBase 'docker.io', got '%s'", registryBase) + } + if repoName != "myuser/myblueprint" { + t.Errorf("Expected repoName 'myuser/myblueprint', got '%s'", repoName) + } + if tag != "" { + t.Errorf("Expected empty tag, got '%s'", tag) + } + }) + + t.Run("ParsesRegistryURLWithOCIPrefix", func(t *testing.T) { + // Given a registry URL with oci:// prefix + url := "oci://registry.example.com/namespace/repo:latest" + + // When parsing the URL + registryBase, repoName, tag, err := ParseRegistryURL(url) + + // Then parsing should succeed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And prefix should be stripped + if registryBase != "registry.example.com" { + t.Errorf("Expected registryBase 'registry.example.com', got '%s'", registryBase) + } + if repoName != "namespace/repo" { + t.Errorf("Expected repoName 'namespace/repo', got '%s'", repoName) + } + if tag != "latest" { + t.Errorf("Expected tag 'latest', got '%s'", tag) + } + }) + + t.Run("ParsesRegistryURLWithMultipleSlashes", func(t *testing.T) { + // Given a registry URL with nested repository path + url := "registry.com/org/project/subproject:v2.0" + + // When parsing the URL + registryBase, repoName, tag, err := ParseRegistryURL(url) + + // Then parsing should succeed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And full repository path should be preserved + if registryBase != "registry.com" { + t.Errorf("Expected registryBase 'registry.com', got '%s'", registryBase) + } + if repoName != "org/project/subproject" { + t.Errorf("Expected repoName 'org/project/subproject', got '%s'", repoName) + } + if tag != "v2.0" { + t.Errorf("Expected tag 'v2.0', got '%s'", tag) + } + }) + + t.Run("ReturnsErrorForInvalidFormatWithoutSlash", func(t *testing.T) { + // Given an invalid registry URL without slash + url := "registry.example.com" + + // When parsing the URL + registryBase, repoName, tag, err := ParseRegistryURL(url) + + // Then error should be returned + if err == nil { + t.Error("Expected error for invalid format, got nil") + } + + // And error should indicate invalid format + if !strings.Contains(err.Error(), "invalid registry format") { + t.Errorf("Expected error about invalid format, got: %v", err) + } + + // And components should be empty + if registryBase != "" || repoName != "" || tag != "" { + t.Errorf("Expected empty components on error, got: base=%s, repo=%s, tag=%s", registryBase, repoName, tag) + } + }) + + t.Run("ReturnsErrorForEmptyString", func(t *testing.T) { + // Given an empty URL + url := "" + + // When parsing the URL + registryBase, repoName, tag, err := ParseRegistryURL(url) + + // Then error should be returned + if err == nil { + t.Error("Expected error for empty string, got nil") + } + + // And components should be empty + if registryBase != "" || repoName != "" || tag != "" { + t.Errorf("Expected empty components on error, got: base=%s, repo=%s, tag=%s", registryBase, repoName, tag) + } + }) + + t.Run("HandlesRegistryURLWithColonInTag", func(t *testing.T) { + // Given a registry URL with multiple colons (edge case) + // The parser uses the last colon to separate repo from tag + url := "registry.com/repo:tag:with:colons" + + // When parsing the URL + registryBase, repoName, tag, err := ParseRegistryURL(url) + + // Then parsing should succeed using last colon + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And components should be correct (last colon is used for tag) + if registryBase != "registry.com" { + t.Errorf("Expected registryBase 'registry.com', got '%s'", registryBase) + } + if repoName != "repo:tag:with" { + t.Errorf("Expected repoName 'repo:tag:with', got '%s'", repoName) + } + if tag != "colons" { + t.Errorf("Expected tag 'colons', got '%s'", tag) + } + }) +} +func TestIsAuthenticationError(t *testing.T) { + t.Run("ReturnsTrueForUNAUTHORIZED", func(t *testing.T) { + // Given an error with UNAUTHORIZED + err := fmt.Errorf("UNAUTHORIZED: access denied") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for UNAUTHORIZED error") + } + }) + + t.Run("ReturnsTrueForUnauthorized", func(t *testing.T) { + // Given an error with unauthorized + err := fmt.Errorf("unauthorized access") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for unauthorized error") + } + }) + + t.Run("ReturnsTrueForAuthenticationRequired", func(t *testing.T) { + // Given an error with authentication required + err := fmt.Errorf("authentication required to access this resource") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for authentication required error") + } + }) + + t.Run("ReturnsTrueForAuthenticationFailed", func(t *testing.T) { + // Given an error with authentication failed + err := fmt.Errorf("authentication failed") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for authentication failed error") + } + }) + + t.Run("ReturnsTrueForHTTP401", func(t *testing.T) { + // Given an error with HTTP 401 + err := fmt.Errorf("HTTP 401: unauthorized") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for HTTP 401 error") + } + }) + + t.Run("ReturnsTrueForHTTP403", func(t *testing.T) { + // Given an error with HTTP 403 + err := fmt.Errorf("HTTP 403: forbidden") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for HTTP 403 error") + } + }) + + t.Run("ReturnsTrueForBlobsUploads", func(t *testing.T) { + // Given an error with blobs/uploads + err := fmt.Errorf("POST https://registry.com/v2/repo/blobs/uploads: unauthorized") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for blobs/uploads error") + } + }) + + t.Run("ReturnsTrueForPOSTHTTPS", func(t *testing.T) { + // Given an error with POST https:// + err := fmt.Errorf("POST https://registry.com/v2/repo/manifests/latest: unauthorized") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for POST https:// error") + } + }) + + t.Run("ReturnsTrueForFailedToPushArtifact", func(t *testing.T) { + // Given an error with failed to push artifact + err := fmt.Errorf("failed to push artifact: unauthorized") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for failed to push artifact error") + } + }) + + t.Run("ReturnsTrueForUserCannotBeAuthenticated", func(t *testing.T) { + // Given an error with User cannot be authenticated + err := fmt.Errorf("User cannot be authenticated") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return true + if !result { + t.Error("Expected true for User cannot be authenticated error") + } + }) + + t.Run("ReturnsFalseForNilError", func(t *testing.T) { + // Given a nil error + var err error + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return false + if result { + t.Error("Expected false for nil error") + } + }) + + t.Run("ReturnsFalseForGenericError", func(t *testing.T) { + // Given a generic error + err := fmt.Errorf("network timeout") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return false + if result { + t.Error("Expected false for generic error") + } + }) + + t.Run("ReturnsFalseForParseError", func(t *testing.T) { + // Given a parse error + err := fmt.Errorf("failed to parse JSON") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return false + if result { + t.Error("Expected false for parse error") + } + }) + + t.Run("ReturnsFalseForNotFoundError", func(t *testing.T) { + // Given a not found error + err := fmt.Errorf("resource not found") + + // When checking if it's an authentication error + result := IsAuthenticationError(err) + + // Then it should return false + if result { + t.Error("Expected false for not found error") + } + }) +} +func TestValidateCliVersion(t *testing.T) { + t.Run("ReturnsNilWhenConstraintIsEmpty", func(t *testing.T) { + // Given an empty constraint + // When validating + err := ValidateCliVersion("1.0.0", "") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for empty constraint, got: %v", err) + } + }) + + t.Run("ReturnsNilWhenCliVersionIsEmpty", func(t *testing.T) { + // Given an empty CLI version + // When validating + err := ValidateCliVersion("", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for empty CLI version, got: %v", err) + } + }) + + t.Run("ReturnsNilForDevVersion", func(t *testing.T) { + // Given dev version + // When validating + err := ValidateCliVersion("dev", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for dev version, got: %v", err) + } + }) + + t.Run("ReturnsNilForMainVersion", func(t *testing.T) { + // Given main version + // When validating + err := ValidateCliVersion("main", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for main version, got: %v", err) + } + }) + + t.Run("ReturnsNilForLatestVersion", func(t *testing.T) { + // Given latest version + // When validating + err := ValidateCliVersion("latest", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for latest version, got: %v", err) + } + }) + + t.Run("ReturnsErrorForInvalidCliVersionFormat", func(t *testing.T) { + // Given an invalid CLI version format + // When validating + err := ValidateCliVersion("invalid-version", ">=1.0.0") + + // Then should return error + if err == nil { + t.Error("Expected error for invalid CLI version format") + } + if !strings.Contains(err.Error(), "invalid CLI version format") { + t.Errorf("Expected error to contain 'invalid CLI version format', got: %v", err) + } + }) + + t.Run("ReturnsErrorForInvalidConstraint", func(t *testing.T) { + // Given an invalid constraint + // When validating + err := ValidateCliVersion("1.0.0", "invalid-constraint") + + // Then should return error + if err == nil { + t.Error("Expected error for invalid constraint") + } + if !strings.Contains(err.Error(), "invalid cliVersion constraint") { + t.Errorf("Expected error to contain 'invalid cliVersion constraint', got: %v", err) + } + }) + + t.Run("ReturnsErrorWhenVersionDoesNotSatisfyConstraint", func(t *testing.T) { + // Given a version that doesn't satisfy constraint + // When validating + err := ValidateCliVersion("1.0.0", ">=2.0.0") + + // Then should return error + if err == nil { + t.Error("Expected error when version doesn't satisfy constraint") + } + if !strings.Contains(err.Error(), "does not satisfy required constraint") { + t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionSatisfiesGreaterThanConstraint", func(t *testing.T) { + // Given a version that satisfies >= constraint + // When validating + err := ValidateCliVersion("2.0.0", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for satisfied constraint, got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionSatisfiesLessThanConstraint", func(t *testing.T) { + // Given a version that satisfies < constraint + // When validating + err := ValidateCliVersion("1.0.0", "<2.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for satisfied constraint, got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionSatisfiesRangeConstraint", func(t *testing.T) { + // Given a version that satisfies range constraint + // When validating + err := ValidateCliVersion("1.5.0", ">=1.0.0 <2.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for satisfied range constraint, got: %v", err) + } + }) + + t.Run("ReturnsErrorWhenVersionOutsideRange", func(t *testing.T) { + // Given a version outside range + // When validating + err := ValidateCliVersion("2.5.0", ">=1.0.0 <2.0.0") + + // Then should return error + if err == nil { + t.Error("Expected error when version outside range") + } + if !strings.Contains(err.Error(), "does not satisfy required constraint") { + t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionSatisfiesTildeConstraint", func(t *testing.T) { + // Given a version that satisfies ~ constraint + // When validating + err := ValidateCliVersion("1.2.3", "~1.2.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for satisfied tilde constraint, got: %v", err) + } + }) + + t.Run("ReturnsErrorWhenVersionDoesNotSatisfyTildeConstraint", func(t *testing.T) { + // Given a version that doesn't satisfy ~ constraint + // When validating + err := ValidateCliVersion("1.3.0", "~1.2.0") + + // Then should return error + if err == nil { + t.Error("Expected error when version doesn't satisfy tilde constraint") + } + if !strings.Contains(err.Error(), "does not satisfy required constraint") { + t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionWithVPrefixSatisfiesConstraint", func(t *testing.T) { + // Given a version with v prefix that satisfies constraint + // When validating + err := ValidateCliVersion("v1.0.0", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for v-prefixed version satisfying constraint, got: %v", err) + } + }) + + t.Run("ReturnsErrorWhenVersionWithVPrefixDoesNotSatisfyConstraint", func(t *testing.T) { + // Given a version with v prefix that doesn't satisfy constraint + // When validating + err := ValidateCliVersion("v0.5.0", ">=1.0.0") + + // Then should return error + if err == nil { + t.Error("Expected error when v-prefixed version doesn't satisfy constraint") + } + if !strings.Contains(err.Error(), "does not satisfy required constraint") { + t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + } + }) +} diff --git a/pkg/composer/artifact/artifact_private_test.go b/pkg/composer/artifact/artifact_private_test.go new file mode 100644 index 000000000..4a77eb7ce --- /dev/null +++ b/pkg/composer/artifact/artifact_private_test.go @@ -0,0 +1,1792 @@ +package artifact + +import ( + "archive/tar" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goccy/go-yaml" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +func TestArtifactBuilder_resolveOutputPath(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("GeneratesFilenameInCurrentDirectory", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path for current directory + actualPath := builder.resolveOutputPath(".", "testproject", "v1.0.0") + + // Then filename should be generated in current directory + expectedPath := "testproject-v1.0.0.tar.gz" + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("GeneratesFilenameInSpecifiedDirectory", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path for directory without extension + actualPath := builder.resolveOutputPath("output", "testproject", "v1.0.0") + + // Then filename should be generated in that directory + expectedPath := filepath.Join("output", "testproject-v1.0.0.tar.gz") + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("GeneratesFilenameWithTrailingSlash", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path with trailing slash + actualPath := builder.resolveOutputPath("output/", "testproject", "v1.0.0") + + // Then filename should be generated in that directory + expectedPath := filepath.Join("output", "testproject-v1.0.0.tar.gz") + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("UsesExplicitFilename", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path with explicit filename + explicitPath := "custom-name.tar.gz" + actualPath := builder.resolveOutputPath(explicitPath, "testproject", "v1.0.0") + + // Then explicit filename should be used + if actualPath != explicitPath { + t.Errorf("Expected path %s, got %s", explicitPath, actualPath) + } + }) + + t.Run("UsesExplicitPathWithFilename", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path with directory and filename + explicitPath := filepath.Join("output", "custom-name.tar.gz") + actualPath := builder.resolveOutputPath(explicitPath, "testproject", "v1.0.0") + + // Then explicit path should be used + if actualPath != explicitPath { + t.Errorf("Expected path %s, got %s", explicitPath, actualPath) + } + }) +} +func TestArtifactBuilder_createTarballInMemory(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("SuccessWithFiles", func(t *testing.T) { + // Given a builder with files and metadata + builder, _ := setup(t) + builder.addFile("test.txt", []byte("content"), 0644) + builder.addFile("other.txt", []byte("other content"), 0644) + metadata := []byte("name: test\nversion: v1.0.0\n") + + // When creating tarball in memory + result, err := builder.createTarballInMemory(metadata) + + // Then should succeed + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(result) == 0 { + t.Error("Expected non-empty tarball") + } + }) + + t.Run("SuccessSkipsMetadataFile", func(t *testing.T) { + // Given a builder with _templates/metadata.yaml file + builder, _ := setup(t) + builder.addFile("_templates/metadata.yaml", []byte("original metadata"), 0644) + builder.addFile("test.txt", []byte("content"), 0644) + metadata := []byte("name: test\nversion: v1.0.0\n") + + // When creating tarball in memory + result, err := builder.createTarballInMemory(metadata) + + // Then should succeed + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(result) == 0 { + t.Error("Expected non-empty tarball") + } + }) + + t.Run("ErrorWhenTarWriterWriteHeaderFails", func(t *testing.T) { + // Given a builder with files + builder, mocks := setup(t) + builder.addFile("test.txt", []byte("content"), 0644) + metadata := []byte("name: test\nversion: v1.0.0\n") + + // Mock tar writer to fail on WriteHeader for metadata + mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { + return &mockTarWriter{ + writeHeaderFunc: func(*tar.Header) error { + return fmt.Errorf("write header failed") + }, + } + } + + // When creating tarball in memory + _, err := builder.createTarballInMemory(metadata) + + // Then should return error + if err == nil { + t.Fatal("Expected error when tar writer header fails") + } + if !strings.Contains(err.Error(), "failed to write metadata header") { + t.Errorf("Expected error to contain 'failed to write metadata header', got %v", err) + } + }) + + t.Run("ErrorWhenTarWriterWriteFails", func(t *testing.T) { + // Given a builder with files + builder, mocks := setup(t) + builder.addFile("test.txt", []byte("content"), 0644) + metadata := []byte("name: test\nversion: v1.0.0\n") + + // Mock tar writer to fail on Write for metadata + mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { + return &mockTarWriter{ + writeHeaderFunc: func(*tar.Header) error { + return nil + }, + writeFunc: func([]byte) (int, error) { + return 0, fmt.Errorf("write failed") + }, + } + } + + // When creating tarball in memory + _, err := builder.createTarballInMemory(metadata) + + // Then should return error + if err == nil { + t.Fatal("Expected error when tar writer write fails") + } + if !strings.Contains(err.Error(), "failed to write metadata") { + t.Errorf("Expected error to contain 'failed to write metadata', got %v", err) + } + }) + + t.Run("ErrorWhenFileHeaderWriteFails", func(t *testing.T) { + // Given a builder with files + builder, mocks := setup(t) + builder.addFile("test.txt", []byte("content"), 0644) + metadata := []byte("name: test\nversion: v1.0.0\n") + + headerCount := 0 + // Mock tar writer to fail on second WriteHeader (for file) + mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { + return &mockTarWriter{ + writeHeaderFunc: func(hdr *tar.Header) error { + headerCount++ + if headerCount > 1 { + return fmt.Errorf("file header write failed") + } + return nil + }, + writeFunc: func([]byte) (int, error) { + return 100, nil + }, + } + } + + // When creating tarball in memory + _, err := builder.createTarballInMemory(metadata) + + // Then should return error + if err == nil { + t.Fatal("Expected error when file header write fails") + } + if !strings.Contains(err.Error(), "failed to write header for test.txt") { + t.Errorf("Expected error to contain 'failed to write header for test.txt', got %v", err) + } + }) + + t.Run("ErrorWhenFileContentWriteFails", func(t *testing.T) { + // Given a builder with files + builder, mocks := setup(t) + builder.addFile("test.txt", []byte("content"), 0644) + metadata := []byte("name: test\nversion: v1.0.0\n") + + writeCount := 0 + // Mock tar writer to fail on second Write (for file content) + mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { + return &mockTarWriter{ + writeHeaderFunc: func(*tar.Header) error { + return nil + }, + writeFunc: func([]byte) (int, error) { + writeCount++ + if writeCount > 1 { + return 0, fmt.Errorf("file content write failed") + } + return 100, nil + }, + } + } + + // When creating tarball in memory + _, err := builder.createTarballInMemory(metadata) + + // Then should return error + if err == nil { + t.Fatal("Expected error when file content write fails") + } + if !strings.Contains(err.Error(), "failed to write content for test.txt") { + t.Errorf("Expected error to contain 'failed to write content for test.txt', got %v", err) + } + }) + + t.Run("ErrorWhenTarWriterCloseFails", func(t *testing.T) { + // Given a builder with files + builder, mocks := setup(t) + builder.addFile("test.txt", []byte("content"), 0644) + metadata := []byte("name: test\nversion: v1.0.0\n") + + // Mock tar writer to fail on Close + mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { + return &mockTarWriter{ + writeHeaderFunc: func(*tar.Header) error { + return nil + }, + writeFunc: func([]byte) (int, error) { + return 100, nil + }, + closeFunc: func() error { + return fmt.Errorf("tar writer close failed") + }, + } + } + + // When creating tarball in memory + _, err := builder.createTarballInMemory(metadata) + + // Then should return error + if err == nil { + t.Fatal("Expected error when tar writer close fails") + } + if !strings.Contains(err.Error(), "failed to close tar writer") { + t.Errorf("Expected error to contain 'failed to close tar writer', got %v", err) + } + }) + + t.Run("SuccessWithEmptyFiles", func(t *testing.T) { + // Given a builder with no files, only metadata + builder, _ := setup(t) + metadata := []byte("name: test\nversion: v1.0.0\n") + + // When creating tarball in memory + result, err := builder.createTarballInMemory(metadata) + + // Then should succeed + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(result) == 0 { + t.Error("Expected non-empty tarball") + } + }) + +} + +// ============================================================================= +// Test generateMetadataWithNameVersion +// ============================================================================= + +func TestArtifactBuilder_generateMetadataWithNameVersion(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("SuccessWithGitProvenanceAndBuilderInfo", func(t *testing.T) { + // Given a builder with shell configured + builder, mocks := setup(t) + + // Mock successful git operations + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + switch { + case strings.Contains(cmd, "git rev-parse HEAD"): + return "abc123def456", nil + case strings.Contains(cmd, "git describe --tags --exact-match HEAD"): + return "v1.0.0", nil + case strings.Contains(cmd, "git config --get remote.origin.url"): + return "https://github.com/example/repo.git", nil + case strings.Contains(cmd, "git config --get user.name"): + return "Test User", nil + case strings.Contains(cmd, "git config --get user.email"): + return "test@example.com", nil + default: + return "", nil + } + } + + // Override YamlMarshal to actually marshal the data + mocks.Shims.YamlMarshal = func(data any) ([]byte, error) { + return yaml.Marshal(data) + } + + input := BlueprintMetadataInput{ + Description: "Test description", + Author: "Test Author", + Tags: []string{"test", "example"}, + Homepage: "https://example.com", + License: "MIT", + CliVersion: ">=1.0.0", + } + + // When generating metadata + metadata, err := builder.generateMetadataWithNameVersion(input, "testapp", "1.0.0") + + // Then should succeed + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + if metadata == nil { + t.Error("Expected metadata to be generated") + } + + // And cliVersion should be preserved in generated metadata + var generatedMetadata BlueprintMetadata + if err := yaml.Unmarshal(metadata, &generatedMetadata); err != nil { + t.Fatalf("Failed to unmarshal generated metadata: %v", err) + } + if generatedMetadata.CliVersion != ">=1.0.0" { + t.Errorf("Expected cliVersion to be '>=1.0.0', got '%s'", generatedMetadata.CliVersion) + } + }) + + t.Run("SuccessWithGitProvenanceFailure", func(t *testing.T) { + // Given a builder with shell configured to fail git operations + builder, mocks := setup(t) + + // Mock git operations to fail + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + return "", fmt.Errorf("git command failed") + } + + input := BlueprintMetadataInput{ + Description: "Test description", + } + + // When generating metadata + metadata, err := builder.generateMetadataWithNameVersion(input, "testapp", "1.0.0") + + // Then should succeed with empty git provenance + if err != nil { + t.Errorf("Expected success despite git failures, got error: %v", err) + } + if metadata == nil { + t.Error("Expected metadata to be generated") + } + }) + + t.Run("ErrorWhenYamlMarshalFails", func(t *testing.T) { + // Given a builder with failing YAML marshal + builder, mocks := setup(t) + mocks.Shims.YamlMarshal = func(data any) ([]byte, error) { + return nil, fmt.Errorf("yaml marshal failed") + } + + input := BlueprintMetadataInput{} + + // When generating metadata + _, err := builder.generateMetadataWithNameVersion(input, "testapp", "1.0.0") + + // Then should get marshal error + if err == nil || !strings.Contains(err.Error(), "yaml marshal failed") { + t.Errorf("Expected yaml marshal error, got: %v", err) + } + }) +} + +func TestArtifactBuilder_getGitProvenance(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("SuccessWithAllGitInfo", func(t *testing.T) { + // Given a builder with successful git operations + builder, mocks := setup(t) + + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + switch { + case strings.Contains(cmd, "git rev-parse HEAD"): + return " abc123def456 ", nil // With whitespace to test trimming + case strings.Contains(cmd, "git describe --tags --exact-match HEAD"): + return " v1.0.0 ", nil // With whitespace to test trimming + case strings.Contains(cmd, "git config --get remote.origin.url"): + return " https://github.com/example/repo.git ", nil // With whitespace + default: + return "", fmt.Errorf("unexpected command: %s", cmd) + } + } + + // When getting git provenance + provenance, err := builder.getGitProvenance() + + // Then should succeed with trimmed values + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + if provenance.CommitSHA != "abc123def456" { + t.Errorf("Expected commit SHA 'abc123def456', got '%s'", provenance.CommitSHA) + } + if provenance.Tag != "v1.0.0" { + t.Errorf("Expected tag 'v1.0.0', got '%s'", provenance.Tag) + } + if provenance.RemoteURL != "https://github.com/example/repo.git" { + t.Errorf("Expected remote URL 'https://github.com/example/repo.git', got '%s'", provenance.RemoteURL) + } + }) + + t.Run("ErrorWhenCommitSHAFails", func(t *testing.T) { + // Given a builder with failing commit SHA command + builder, mocks := setup(t) + + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + if strings.Contains(cmd, "git rev-parse HEAD") { + return "", fmt.Errorf("not a git repository") + } + return "", nil + } + + // When getting git provenance + _, err := builder.getGitProvenance() + + // Then should get commit SHA error + if err == nil || !strings.Contains(err.Error(), "failed to get commit SHA") { + t.Errorf("Expected commit SHA error, got: %v", err) + } + }) + + t.Run("SuccessWithMissingTag", func(t *testing.T) { + // Given a builder where tag command fails but others succeed + builder, mocks := setup(t) + + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + switch { + case strings.Contains(cmd, "git rev-parse HEAD"): + return "abc123def456", nil + case strings.Contains(cmd, "git describe --tags --exact-match HEAD"): + return "", fmt.Errorf("no tag found") // Tag command fails + case strings.Contains(cmd, "git config --get remote.origin.url"): + return "https://github.com/example/repo.git", nil + default: + return "", fmt.Errorf("unexpected command: %s", cmd) + } + } + + // When getting git provenance + provenance, err := builder.getGitProvenance() + + // Then should succeed with empty tag + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + if provenance.CommitSHA != "abc123def456" { + t.Errorf("Expected commit SHA 'abc123def456', got '%s'", provenance.CommitSHA) + } + if provenance.Tag != "" { + t.Errorf("Expected empty tag, got '%s'", provenance.Tag) + } + if provenance.RemoteURL != "https://github.com/example/repo.git" { + t.Errorf("Expected remote URL, got '%s'", provenance.RemoteURL) + } + }) + + t.Run("SuccessWithMissingRemoteURL", func(t *testing.T) { + // Given a builder where remote URL command fails + builder, mocks := setup(t) + + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + switch { + case strings.Contains(cmd, "git rev-parse HEAD"): + return "abc123def456", nil + case strings.Contains(cmd, "git describe --tags --exact-match HEAD"): + return "v1.0.0", nil + case strings.Contains(cmd, "git config --get remote.origin.url"): + return "", fmt.Errorf("no remote configured") // Remote URL fails + default: + return "", fmt.Errorf("unexpected command: %s", cmd) + } + } + + // When getting git provenance + provenance, err := builder.getGitProvenance() + + // Then should succeed with empty remote URL + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + if provenance.CommitSHA != "abc123def456" { + t.Errorf("Expected commit SHA 'abc123def456', got '%s'", provenance.CommitSHA) + } + if provenance.Tag != "v1.0.0" { + t.Errorf("Expected tag 'v1.0.0', got '%s'", provenance.Tag) + } + if provenance.RemoteURL != "" { + t.Errorf("Expected empty remote URL, got '%s'", provenance.RemoteURL) + } + }) +} + +func TestArtifactBuilder_getBuilderInfo(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("SuccessWithUserAndEmail", func(t *testing.T) { + // Given a builder with configured git user info + builder, mocks := setup(t) + + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + switch { + case strings.Contains(cmd, "git config --get user.name"): + return " Test User ", nil // With whitespace to test trimming + case strings.Contains(cmd, "git config --get user.email"): + return " test@example.com ", nil // With whitespace to test trimming + default: + return "", fmt.Errorf("unexpected command: %s", cmd) + } + } + + // When getting builder info + builderInfo, err := builder.getBuilderInfo() + + // Then should succeed with trimmed values + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + if builderInfo.User != "Test User" { + t.Errorf("Expected user 'Test User', got '%s'", builderInfo.User) + } + if builderInfo.Email != "test@example.com" { + t.Errorf("Expected email 'test@example.com', got '%s'", builderInfo.Email) + } + }) + + t.Run("SuccessWithMissingUserName", func(t *testing.T) { + // Given a builder where user name is not configured + builder, mocks := setup(t) + + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + switch { + case strings.Contains(cmd, "git config --get user.name"): + return "", fmt.Errorf("user.name not configured") + case strings.Contains(cmd, "git config --get user.email"): + return "test@example.com", nil + default: + return "", fmt.Errorf("unexpected command: %s", cmd) + } + } + + // When getting builder info + builderInfo, err := builder.getBuilderInfo() + + // Then should succeed with empty user name + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + if builderInfo.User != "" { + t.Errorf("Expected empty user, got '%s'", builderInfo.User) + } + if builderInfo.Email != "test@example.com" { + t.Errorf("Expected email 'test@example.com', got '%s'", builderInfo.Email) + } + }) + + t.Run("SuccessWithMissingEmail", func(t *testing.T) { + // Given a builder where email is not configured + builder, mocks := setup(t) + + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + switch { + case strings.Contains(cmd, "git config --get user.name"): + return "Test User", nil + case strings.Contains(cmd, "git config --get user.email"): + return "", fmt.Errorf("user.email not configured") + default: + return "", fmt.Errorf("unexpected command: %s", cmd) + } + } + + // When getting builder info + builderInfo, err := builder.getBuilderInfo() + + // Then should succeed with empty email + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + if builderInfo.User != "Test User" { + t.Errorf("Expected user 'Test User', got '%s'", builderInfo.User) + } + if builderInfo.Email != "" { + t.Errorf("Expected empty email, got '%s'", builderInfo.Email) + } + }) + + t.Run("SuccessWithBothMissing", func(t *testing.T) { + // Given a builder where both user and email are not configured + builder, mocks := setup(t) + + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + return "", fmt.Errorf("git config not found") + } + + // When getting builder info + builderInfo, err := builder.getBuilderInfo() + + // Then should succeed with empty values + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + if builderInfo.User != "" { + t.Errorf("Expected empty user, got '%s'", builderInfo.User) + } + if builderInfo.Email != "" { + t.Errorf("Expected empty email, got '%s'", builderInfo.Email) + } + }) +} + +func TestArtifactBuilder_createOCIArtifactImage(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("SuccessWithValidLayer", func(t *testing.T) { + // Given a builder with successful shim operations + builder, mocks := setup(t) + + // Mock git provenance to return test data + expectedCommitSHA := "abc123def456" + expectedRemoteURL := "https://github.com/user/repo.git" + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + if strings.Contains(cmd, "git rev-parse HEAD") { + return expectedCommitSHA, nil + } + if strings.Contains(cmd, "git config --get remote.origin.url") { + return expectedRemoteURL, nil + } + return "", nil + } + + // Mock successful image creation + mockImage := &mockImage{} + mocks.Shims.EmptyImage = func() v1.Image { return mockImage } + mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { + return mockImage, nil + } + mocks.Shims.ConfigFile = func(img v1.Image, cfg *v1.ConfigFile) (v1.Image, error) { + if cfg.Architecture != "amd64" { + return nil, fmt.Errorf("expected amd64 architecture, got %s", cfg.Architecture) + } + if cfg.OS != "linux" { + return nil, fmt.Errorf("expected linux OS, got %s", cfg.OS) + } + if cfg.Config.Labels["org.opencontainers.image.title"] != "test-repo" { + return nil, fmt.Errorf("expected title label to be test-repo") + } + return mockImage, nil + } + mocks.Shims.MediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } + mocks.Shims.ConfigMediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } + mocks.Shims.Annotations = func(img v1.Image, anns map[string]string) v1.Image { + if anns["org.opencontainers.image.revision"] != expectedCommitSHA { + t.Errorf("Expected revision %s, got %s", expectedCommitSHA, anns["org.opencontainers.image.revision"]) + } + if anns["org.opencontainers.image.source"] != expectedRemoteURL { + t.Errorf("Expected source %s, got %s", expectedRemoteURL, anns["org.opencontainers.image.source"]) + } + return mockImage + } + + layer := static.NewLayer([]byte("test"), types.DockerLayer) + + // When creating OCI artifact image + img, err := builder.createOCIArtifactImage(layer, "test-repo", "v1.0.0") + + // Then should succeed + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if img == nil { + t.Error("Expected non-nil image") + } + }) + + t.Run("ErrorWhenAppendLayersFails", func(t *testing.T) { + // Given a builder with failing AppendLayers + builder, mocks := setup(t) + + // Mock git provenance to succeed but AppendLayers to fail + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + if strings.Contains(cmd, "git rev-parse HEAD") { + return "abc123", nil + } + return "", nil + } + + mocks.Shims.EmptyImage = func() v1.Image { return &mockImage{} } + mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { + return nil, fmt.Errorf("append layers failed") + } + + layer := static.NewLayer([]byte("test"), types.DockerLayer) + + // When creating OCI artifact image + _, err := builder.createOCIArtifactImage(layer, "test-repo", "v1.0.0") + + // Then should return error + if err == nil { + t.Fatal("Expected error when AppendLayers fails") + } + if !strings.Contains(err.Error(), "failed to append layer to image") { + t.Errorf("Expected error to contain 'failed to append layer to image', got %v", err) + } + }) + + t.Run("ErrorWhenConfigFileFails", func(t *testing.T) { + // Given a builder with failing ConfigFile + builder, mocks := setup(t) + + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + return "", nil + } + + mockImage := &mockImage{} + mocks.Shims.EmptyImage = func() v1.Image { return mockImage } + mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { + return mockImage, nil + } + mocks.Shims.ConfigFile = func(img v1.Image, cfg *v1.ConfigFile) (v1.Image, error) { + return nil, fmt.Errorf("config file failed") + } + + layer := static.NewLayer([]byte("test"), types.DockerLayer) + + // When creating OCI artifact image + _, err := builder.createOCIArtifactImage(layer, "test-repo", "v1.0.0") + + // Then should return error + if err == nil { + t.Fatal("Expected error when ConfigFile fails") + } + if !strings.Contains(err.Error(), "failed to set config file") { + t.Errorf("Expected error to contain 'failed to set config file', got %v", err) + } + }) + + t.Run("SuccessWithGitProvenanceFallback", func(t *testing.T) { + // Given a builder where git provenance fails + builder, mocks := setup(t) + + // Mock git provenance to fail + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + return "", fmt.Errorf("git command failed") + } + + // Mock successful image creation + mockImage := &mockImage{} + mocks.Shims.EmptyImage = func() v1.Image { return mockImage } + mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { + return mockImage, nil + } + mocks.Shims.ConfigFile = func(img v1.Image, cfg *v1.ConfigFile) (v1.Image, error) { + return mockImage, nil + } + mocks.Shims.MediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } + mocks.Shims.ConfigMediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } + + // Capture annotations to verify fallback revision and source + mocks.Shims.Annotations = func(img v1.Image, anns map[string]string) v1.Image { + if anns["org.opencontainers.image.revision"] != "unknown" { + t.Errorf("Expected revision 'unknown', got %s", anns["org.opencontainers.image.revision"]) + } + if anns["org.opencontainers.image.source"] != "unknown" { + t.Errorf("Expected source 'unknown', got %s", anns["org.opencontainers.image.source"]) + } + return mockImage + } + + layer := static.NewLayer([]byte("test"), types.DockerLayer) + + // When creating OCI artifact image + img, err := builder.createOCIArtifactImage(layer, "test-repo", "v1.0.0") + + // Then should succeed with fallback values + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if img == nil { + t.Error("Expected non-nil image") + } + }) + + t.Run("SuccessWithEmptyCommitSHA", func(t *testing.T) { + // Given a builder where git returns empty commit SHA but valid remote URL + builder, mocks := setup(t) + + expectedRemoteURL := "https://github.com/user/empty-sha-repo.git" + // Mock git provenance to return empty commit SHA but valid remote URL + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + if strings.Contains(cmd, "git rev-parse HEAD") { + return " ", nil + } + if strings.Contains(cmd, "git config --get remote.origin.url") { + return expectedRemoteURL, nil + } + return "", nil + } + + // Mock successful image creation + mockImage := &mockImage{} + mocks.Shims.EmptyImage = func() v1.Image { return mockImage } + mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { + return mockImage, nil + } + mocks.Shims.ConfigFile = func(img v1.Image, cfg *v1.ConfigFile) (v1.Image, error) { + return mockImage, nil + } + mocks.Shims.MediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } + mocks.Shims.ConfigMediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } + + // Capture annotations to verify fallback revision but valid source + mocks.Shims.Annotations = func(img v1.Image, anns map[string]string) v1.Image { + if anns["org.opencontainers.image.revision"] != "unknown" { + t.Errorf("Expected revision 'unknown' for empty SHA, got %s", anns["org.opencontainers.image.revision"]) + } + if anns["org.opencontainers.image.source"] != expectedRemoteURL { + t.Errorf("Expected source %s, got %s", expectedRemoteURL, anns["org.opencontainers.image.source"]) + } + return mockImage + } + + layer := static.NewLayer([]byte("test"), types.DockerLayer) + + // When creating OCI artifact image + img, err := builder.createOCIArtifactImage(layer, "test-repo", "v1.0.0") + + // Then should succeed + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if img == nil { + t.Error("Expected non-nil image") + } + }) +} + +func TestArtifactBuilder_parseOCIRef(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("ValidOCIReference", func(t *testing.T) { + // Given an ArtifactBuilder + builder, _ := setup(t) + + // When parseOCIRef is called with valid OCI reference + registry, repository, tag, err := builder.parseOCIRef("oci://registry.example.com/my-repo:v1.0.0") + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And the components should be parsed correctly + if registry != "registry.example.com" { + t.Errorf("expected registry 'registry.example.com', got %s", registry) + } + if repository != "my-repo" { + t.Errorf("expected repository 'my-repo', got %s", repository) + } + if tag != "v1.0.0" { + t.Errorf("expected tag 'v1.0.0', got %s", tag) + } + }) + + t.Run("InvalidOCIPrefix", func(t *testing.T) { + // Given an ArtifactBuilder + builder, _ := setup(t) + + // When parseOCIRef is called with invalid prefix + _, _, _, err := builder.parseOCIRef("https://registry.example.com/my-repo:v1.0.0") + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + expectedError := "invalid OCI reference format: https://registry.example.com/my-repo:v1.0.0" + if err.Error() != expectedError { + t.Errorf("expected error %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("MissingTag", func(t *testing.T) { + // Given an ArtifactBuilder + builder, _ := setup(t) + + // When parseOCIRef is called with missing tag + _, _, _, err := builder.parseOCIRef("oci://registry.example.com/my-repo") + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + expectedError := "invalid OCI reference format, expected registry/repository:tag: oci://registry.example.com/my-repo" + if err.Error() != expectedError { + t.Errorf("expected error %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("MissingRepository", func(t *testing.T) { + // Given an ArtifactBuilder + builder, _ := setup(t) + + // When parseOCIRef is called with missing repository + _, _, _, err := builder.parseOCIRef("oci://registry.example.com:v1.0.0") + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + expectedError := "invalid OCI reference format, expected registry/repository:tag: oci://registry.example.com:v1.0.0" + if err.Error() != expectedError { + t.Errorf("expected error %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("NestedRepositoryPath", func(t *testing.T) { + // Given an ArtifactBuilder + builder, _ := setup(t) + + // When parseOCIRef is called with nested repository path + registry, repository, tag, err := builder.parseOCIRef("oci://registry.example.com/organization/my-repo:v1.0.0") + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And the components should be parsed correctly + if registry != "registry.example.com" { + t.Errorf("expected registry 'registry.example.com', got %s", registry) + } + if repository != "organization/my-repo" { + t.Errorf("expected repository 'organization/my-repo', got %s", repository) + } + if tag != "v1.0.0" { + t.Errorf("expected tag 'v1.0.0', got %s", tag) + } + }) +} + +func TestArtifactBuilder_downloadOCIArtifact(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("ParseReferenceSuccess", func(t *testing.T) { + // Given an ArtifactBuilder with successful parse but failing remote + builder, mocks := setup(t) + + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, nil // Success + } + + mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return nil, fmt.Errorf("remote image error") // Fail at next step + } + + // When downloadOCIArtifact is called + _, err := builder.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") + + // Then it should fail at remote image step + if err == nil { + t.Error("expected error for remote image failure") + } + if !strings.Contains(err.Error(), "failed to get image") { + t.Errorf("expected remote image error, got %v", err) + } + }) + + t.Run("ParseReferenceError", func(t *testing.T) { + // Given an ArtifactBuilder with parse reference error + builder, mocks := setup(t) + + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, fmt.Errorf("parse error") + } + + // When downloadOCIArtifact is called + _, err := builder.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") + + // Then it should return parse reference error + if err == nil { + t.Error("expected error for parse reference failure") + } + if !strings.Contains(err.Error(), "failed to parse reference") { + t.Errorf("expected parse reference error, got %v", err) + } + }) + + t.Run("RemoteImageError", func(t *testing.T) { + // Given an ArtifactBuilder with remote image error + builder, mocks := setup(t) + + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, nil + } + + mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return nil, fmt.Errorf("remote error") + } + + // When downloadOCIArtifact is called + _, err := builder.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") + + // Then it should return remote image error + if err == nil { + t.Error("expected error for remote image failure") + } + if !strings.Contains(err.Error(), "failed to get image") { + t.Errorf("expected remote image error, got %v", err) + } + }) + + t.Run("ImageLayersError", func(t *testing.T) { + // Given an ArtifactBuilder with image layers error + builder, mocks := setup(t) + + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, nil + } + + mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return nil, nil + } + + mocks.Shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { + return nil, fmt.Errorf("layers error") + } + + // When downloadOCIArtifact is called + _, err := builder.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") + + // Then it should return image layers error + if err == nil { + t.Error("expected error for image layers failure") + } + if !strings.Contains(err.Error(), "failed to get image layers") { + t.Errorf("expected image layers error, got %v", err) + } + }) +} + +func TestArtifactBuilder_findMatchingProcessor(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("FindsMatchingProcessor", func(t *testing.T) { + // Given a builder with processors + builder, _ := setup(t) + + processors := []PathProcessor{ + {Pattern: "contexts/_template"}, + {Pattern: "kustomize"}, + {Pattern: "terraform"}, + } + + // When finding matching processor + processor := builder.findMatchingProcessor("kustomize/file.yaml", processors) + + // Then should find the kustomize processor + if processor == nil { + t.Error("Expected to find matching processor") + } + if processor.Pattern != "kustomize" { + t.Errorf("Expected kustomize pattern, got %s", processor.Pattern) + } + }) + + t.Run("ReturnsNilForNoMatch", func(t *testing.T) { + // Given a builder with processors + builder, _ := setup(t) + + processors := []PathProcessor{ + {Pattern: "contexts/_template"}, + {Pattern: "kustomize"}, + {Pattern: "terraform"}, + } + + // When finding matching processor for non-matching path + processor := builder.findMatchingProcessor("other/file.txt", processors) + + // Then should return nil + if processor != nil { + t.Error("Expected no matching processor") + } + }) + + t.Run("MatchesFirstProcessor", func(t *testing.T) { + // Given a builder with overlapping processors + builder, _ := setup(t) + + processors := []PathProcessor{ + {Pattern: "test"}, + {Pattern: "test/sub"}, + } + + // When finding matching processor + processor := builder.findMatchingProcessor("test/file.txt", processors) + + // Then should find the first matching processor + if processor == nil { + t.Error("Expected to find matching processor") + } + if processor.Pattern != "test" { + t.Errorf("Expected test pattern, got %s", processor.Pattern) + } + }) +} + +// TestArtifactBuilder_shouldSkipTerraformFile tests the shouldSkipTerraformFile method of ArtifactBuilder +func TestArtifactBuilder_shouldSkipTerraformFile(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("SkipsTerraformStateFiles", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When checking terraform state files + shouldSkip := builder.shouldSkipTerraformFile("terraform.tfstate") + shouldSkipBackup := builder.shouldSkipTerraformFile("terraform.tfstate.backup") + + // Then should skip both + if !shouldSkip { + t.Error("Expected to skip terraform.tfstate") + } + if !shouldSkipBackup { + t.Error("Expected to skip terraform.tfstate.backup") + } + }) + + t.Run("SkipsTerraformOverrideFiles", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When checking terraform override files + shouldSkip := builder.shouldSkipTerraformFile("override.tf") + shouldSkipJson := builder.shouldSkipTerraformFile("override.tf.json") + shouldSkipUnderscore := builder.shouldSkipTerraformFile("test_override.tf") + + // Then should skip all + if !shouldSkip { + t.Error("Expected to skip override.tf") + } + if !shouldSkipJson { + t.Error("Expected to skip override.tf.json") + } + if !shouldSkipUnderscore { + t.Error("Expected to skip test_override.tf") + } + }) + + t.Run("SkipsTerraformVarsFiles", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When checking terraform vars files + shouldSkip := builder.shouldSkipTerraformFile("terraform.tfvars") + shouldSkipJson := builder.shouldSkipTerraformFile("terraform.tfvars.json") + + // Then should skip both + if !shouldSkip { + t.Error("Expected to skip terraform.tfvars") + } + if !shouldSkipJson { + t.Error("Expected to skip terraform.tfvars.json") + } + }) + + t.Run("SkipsTerraformPlanFiles", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When checking terraform plan files + shouldSkip := builder.shouldSkipTerraformFile("terraform.tfplan") + + // Then should skip + if !shouldSkip { + t.Error("Expected to skip terraform.tfplan") + } + }) + + t.Run("SkipsTerraformConfigFiles", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When checking terraform config files + shouldSkipRc := builder.shouldSkipTerraformFile(".terraformrc") + shouldSkipTerraformRc := builder.shouldSkipTerraformFile("terraform.rc") + + // Then should skip both + if !shouldSkipRc { + t.Error("Expected to skip .terraformrc") + } + if !shouldSkipTerraformRc { + t.Error("Expected to skip terraform.rc") + } + }) + + t.Run("SkipsCrashLogFiles", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When checking crash log files + shouldSkip := builder.shouldSkipTerraformFile("crash.log") + shouldSkipPrefixed := builder.shouldSkipTerraformFile("crash.123.log") + + // Then should skip both + if !shouldSkip { + t.Error("Expected to skip crash.log") + } + if !shouldSkipPrefixed { + t.Error("Expected to skip crash.123.log") + } + }) + + t.Run("DoesNotSkipRegularFiles", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When checking regular terraform files + shouldSkip := builder.shouldSkipTerraformFile("main.tf") + shouldSkipVar := builder.shouldSkipTerraformFile("variables.tf") + shouldSkipOutput := builder.shouldSkipTerraformFile("outputs.tf") + + // Then should not skip any + if shouldSkip { + t.Error("Expected not to skip main.tf") + } + if shouldSkipVar { + t.Error("Expected not to skip variables.tf") + } + if shouldSkipOutput { + t.Error("Expected not to skip outputs.tf") + } + }) +} + +// TestArtifactBuilder_walkAndProcessFiles tests the walkAndProcessFiles method of ArtifactBuilder +func TestArtifactBuilder_walkAndProcessFiles(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("SuccessWithMatchingFiles", func(t *testing.T) { + // Given a builder with processors + builder, mocks := setup(t) + + processors := []PathProcessor{ + { + Pattern: "test", + Handler: func(relPath string, data []byte, mode os.FileMode) error { + return builder.addFile("test/"+relPath, data, mode) + }, + }, + } + + // Mock directory exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "test" { + return &mockFileInfo{name: "test", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + // Mock walk function + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + if root == "test" { + fn("test", &mockFileInfo{name: "test", isDir: true}, nil) + fn("test/file.txt", &mockFileInfo{name: "file.txt", isDir: false}, nil) + } + return nil + } + + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("test content"), nil + } + + mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "file.txt", nil + } + + // When walking and processing files + err := builder.walkAndProcessFiles(processors) + + // Then should succeed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And should have added files + if len(builder.files) == 0 { + t.Error("Expected files to be added") + } + }) + + t.Run("SuccessWithNoMatchingFiles", func(t *testing.T) { + // Given a builder with processors that don't match + builder, mocks := setup(t) + + processors := []PathProcessor{ + { + Pattern: "other", + Handler: func(relPath string, data []byte, mode os.FileMode) error { + return builder.addFile("other/"+relPath, data, mode) + }, + }, + } + + // Mock directory exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "test" { + return &mockFileInfo{name: "test", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + // Mock walk function + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + if root == "test" { + fn("test", &mockFileInfo{name: "test", isDir: true}, nil) + fn("test/file.txt", &mockFileInfo{name: "file.txt", isDir: false}, nil) + } + return nil + } + + // When walking and processing files + err := builder.walkAndProcessFiles(processors) + + // Then should succeed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And should not have added files + if len(builder.files) != 0 { + t.Error("Expected no files to be added") + } + }) + + t.Run("SuccessWithSkipTerraformDirectory", func(t *testing.T) { + // Given a builder with terraform directory + builder, mocks := setup(t) + + processors := []PathProcessor{ + { + Pattern: "terraform", + Handler: func(relPath string, data []byte, mode os.FileMode) error { + return builder.addFile("terraform/"+relPath, data, mode) + }, + }, + } + + // Mock directory exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "terraform" { + return &mockFileInfo{name: "terraform", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + // Mock walk function with .terraform directory + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + if root == "terraform" { + fn("terraform", &mockFileInfo{name: "terraform", isDir: true}, nil) + fn("terraform/.terraform", &mockFileInfo{name: ".terraform", isDir: true}, nil) + fn("terraform/main.tf", &mockFileInfo{name: "main.tf", isDir: false}, nil) + } + return nil + } + + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("test content"), nil + } + + mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "main.tf", nil + } + + // When walking and processing files + err := builder.walkAndProcessFiles(processors) + + // Then should succeed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And should have added files (but not .terraform contents) + if len(builder.files) == 0 { + t.Error("Expected files to be added") + } + }) + + t.Run("SuccessWithMissingDirectories", func(t *testing.T) { + // Given a builder with missing directories + builder, mocks := setup(t) + + processors := []PathProcessor{ + { + Pattern: "missing", + Handler: func(relPath string, data []byte, mode os.FileMode) error { + return builder.addFile("missing/"+relPath, data, mode) + }, + }, + } + + // Mock directory doesn't exist + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // When walking and processing files + err := builder.walkAndProcessFiles(processors) + + // Then should succeed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("ErrorOnWalkFailure", func(t *testing.T) { + // Given a builder with walk error + builder, mocks := setup(t) + + processors := []PathProcessor{ + { + Pattern: "test", + Handler: func(relPath string, data []byte, mode os.FileMode) error { + return builder.addFile("test/"+relPath, data, mode) + }, + }, + } + + // Mock directory exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "test" { + return &mockFileInfo{name: "test", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + expectedError := fmt.Errorf("walk error") + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + return expectedError + } + + // When walking and processing files + err := builder.walkAndProcessFiles(processors) + + // Then should return error + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to walk directory") { + t.Errorf("Expected walk error, got %v", err) + } + }) + + t.Run("ErrorWhenWalkCallbackFails", func(t *testing.T) { + // Given a builder where walk callback returns error + builder, mocks := setup(t) + + processors := []PathProcessor{ + { + Pattern: "test", + Handler: func(relPath string, data []byte, mode os.FileMode) error { + return builder.addFile("test/"+relPath, data, mode) + }, + }, + } + + // Mock directory exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "test" { + return &mockFileInfo{name: "test", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + // Mock walk function to call callback with error + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + return fn("test/file.txt", &mockFileInfo{name: "file.txt", isDir: false}, fmt.Errorf("callback error")) + } + + // When walking and processing files + err := builder.walkAndProcessFiles(processors) + + // Then should return error + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to walk directory") { + t.Errorf("Expected walk error, got %v", err) + } + }) + + t.Run("ErrorWhenReadFileFails", func(t *testing.T) { + // Given a builder where ReadFile fails + builder, mocks := setup(t) + + processors := []PathProcessor{ + { + Pattern: "test", + Handler: func(relPath string, data []byte, mode os.FileMode) error { + return builder.addFile("test/"+relPath, data, mode) + }, + }, + } + + // Mock directory exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "test" { + return &mockFileInfo{name: "test", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + // Mock walk function - should return error from callback + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + if root == "test" { + if err := fn("test", &mockFileInfo{name: "test", isDir: true}, nil); err != nil { + return err + } + if err := fn("test/file.txt", &mockFileInfo{name: "file.txt", isDir: false}, nil); err != nil { + return err + } + } + return nil + } + + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return nil, fmt.Errorf("read file error") + } + + mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "file.txt", nil + } + + // When walking and processing files + err := builder.walkAndProcessFiles(processors) + + // Then should return error + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to read file") { + t.Errorf("Expected read file error, got %v", err) + } + }) + + t.Run("ErrorWhenFilepathRelFails", func(t *testing.T) { + // Given a builder where FilepathRel fails + builder, mocks := setup(t) + + processors := []PathProcessor{ + { + Pattern: "test", + Handler: func(relPath string, data []byte, mode os.FileMode) error { + return builder.addFile("test/"+relPath, data, mode) + }, + }, + } + + // Mock directory exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "test" { + return &mockFileInfo{name: "test", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + // Mock walk function - should return error from callback + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + if root == "test" { + if err := fn("test", &mockFileInfo{name: "test", isDir: true}, nil); err != nil { + return err + } + if err := fn("test/file.txt", &mockFileInfo{name: "file.txt", isDir: false}, nil); err != nil { + return err + } + } + return nil + } + + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("test content"), nil + } + + mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "", fmt.Errorf("filepath rel error") + } + + // When walking and processing files + err := builder.walkAndProcessFiles(processors) + + // Then should return error + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to get relative path") { + t.Errorf("Expected filepath rel error, got %v", err) + } + }) + + t.Run("ErrorWhenHandlerFails", func(t *testing.T) { + // Given a builder where handler fails + builder, mocks := setup(t) + + processors := []PathProcessor{ + { + Pattern: "test", + Handler: func(relPath string, data []byte, mode os.FileMode) error { + return fmt.Errorf("handler error") + }, + }, + } + + // Mock directory exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "test" { + return &mockFileInfo{name: "test", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + // Mock walk function - should return error from callback + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + if root == "test" { + if err := fn("test", &mockFileInfo{name: "test", isDir: true}, nil); err != nil { + return err + } + if err := fn("test/file.txt", &mockFileInfo{name: "file.txt", isDir: false}, nil); err != nil { + return err + } + } + return nil + } + + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("test content"), nil + } + + mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "file.txt", nil + } + + // When walking and processing files + err := builder.walkAndProcessFiles(processors) + + // Then should return error + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to walk directory") { + t.Errorf("Expected walk error, got %v", err) + } + }) +} + +// ============================================================================= +// Test Helper Functions +// ============================================================================= diff --git a/pkg/composer/artifact/artifact_test.go b/pkg/composer/artifact/artifact_public_test.go similarity index 55% rename from pkg/composer/artifact/artifact_test.go rename to pkg/composer/artifact/artifact_public_test.go index c40beb0e1..98b600a74 100644 --- a/pkg/composer/artifact/artifact_test.go +++ b/pkg/composer/artifact/artifact_public_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - "github.com/goccy/go-yaml" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -72,105 +71,90 @@ func (m *mockTarWriter) Close() error { return nil } -type ArtifactSetupOptions struct { - Shell shell.Shell -} +// mockImage provides a mock implementation of v1.Image for testing +type mockImage struct{} -func setupArtifactMocks(t *testing.T, opts ...*ArtifactSetupOptions) *ArtifactMocks { - t.Helper() +func (m *mockImage) Layers() ([]v1.Layer, error) { return nil, nil } +func (m *mockImage) MediaType() (types.MediaType, error) { return "", nil } +func (m *mockImage) Size() (int64, error) { return 0, nil } +func (m *mockImage) ConfigName() (v1.Hash, error) { return v1.Hash{}, nil } +func (m *mockImage) ConfigFile() (*v1.ConfigFile, error) { return nil, nil } +func (m *mockImage) RawConfigFile() ([]byte, error) { return nil, nil } +func (m *mockImage) Digest() (v1.Hash, error) { return v1.Hash{}, nil } +func (m *mockImage) Manifest() (*v1.Manifest, error) { return nil, nil } +func (m *mockImage) RawManifest() ([]byte, error) { return nil, nil } +func (m *mockImage) LayerByDigest(v1.Hash) (v1.Layer, error) { return nil, nil } +func (m *mockImage) LayerByDiffID(v1.Hash) (v1.Layer, error) { return nil, nil } - // Create temporary directory for test - tmpDir, err := os.MkdirTemp("", "artifact-test-*") - if err != nil { - t.Fatalf("Failed to create temp directory: %v", err) - } +// Enhanced mock image with configurable behavior for testing different scenarios +type mockImageWithManifest struct { + manifestFunc func() (*v1.Manifest, error) + layerByDigestFunc func(v1.Hash) (v1.Layer, error) + configNameFunc func() (v1.Hash, error) + rawConfigFileFunc func() ([]byte, error) +} - // Change to temporary directory - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Failed to change to temp directory: %v", err) - } +func (m *mockImageWithManifest) Layers() ([]v1.Layer, error) { return nil, nil } +func (m *mockImageWithManifest) MediaType() (types.MediaType, error) { return "", nil } +func (m *mockImageWithManifest) Size() (int64, error) { return 0, nil } +func (m *mockImageWithManifest) ConfigFile() (*v1.ConfigFile, error) { return nil, nil } +func (m *mockImageWithManifest) Digest() (v1.Hash, error) { return v1.Hash{}, nil } +func (m *mockImageWithManifest) RawManifest() ([]byte, error) { return nil, nil } +func (m *mockImageWithManifest) LayerByDiffID(v1.Hash) (v1.Layer, error) { return nil, nil } - // Set up shell - default to MockShell for easier testing - var mockShell *shell.MockShell - if len(opts) > 0 && opts[0].Shell != nil { - if ms, ok := opts[0].Shell.(*shell.MockShell); ok { - mockShell = ms - } else { - mockShell = shell.NewMockShell() - } - } else { - mockShell = shell.NewMockShell() +func (m *mockImageWithManifest) Manifest() (*v1.Manifest, error) { + if m.manifestFunc != nil { + return m.manifestFunc() } + mockImg := &mockImage{} + return mockImg.Manifest() +} - // Set up default shell behaviors - mockShell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - switch { - case strings.Contains(cmd, "git rev-parse HEAD"): - return "abc123def456", nil - case strings.Contains(cmd, "git tag --points-at HEAD"): - return "v1.0.0", nil - case strings.Contains(cmd, "git config --get remote.origin.url"): - return "https://github.com/example/repo.git", nil - case strings.Contains(cmd, "git config user.name"): - return "Test User", nil - case strings.Contains(cmd, "git config user.email"): - return "test@example.com", nil - default: - return "", nil - } +func (m *mockImageWithManifest) LayerByDigest(hash v1.Hash) (v1.Layer, error) { + if m.layerByDigestFunc != nil { + return m.layerByDigestFunc(hash) } + mockImg := &mockImage{} + return mockImg.LayerByDigest(hash) +} - // Use setupShims for consistent shim configuration - shims := setupShims(t) - - // Override specific shims for file system operations - shims.Stat = func(name string) (os.FileInfo, error) { - if name == "." { - // Mock "." as an existing directory - return &mockFileInfo{name: ".", isDir: true}, nil - } - return nil, os.ErrNotExist +func (m *mockImageWithManifest) ConfigName() (v1.Hash, error) { + if m.configNameFunc != nil { + return m.configNameFunc() } - shims.Create = func(name string) (io.WriteCloser, error) { - // Create the full path, handling directories properly - fullPath := name - if !filepath.IsAbs(name) { - fullPath = filepath.Join(tmpDir, name) - } - - // Create directory if needed - dir := filepath.Dir(fullPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return nil, err - } + mockImg := &mockImage{} + return mockImg.ConfigName() +} - return os.Create(fullPath) +func (m *mockImageWithManifest) RawConfigFile() ([]byte, error) { + if m.rawConfigFileFunc != nil { + return m.rawConfigFileFunc() } + mockImg := &mockImage{} + return mockImg.RawConfigFile() +} - // Create runtime - rt := &runtime.Runtime{ - Shell: mockShell, - } +// Mock layer for testing +type mockLayer struct{} - // Cleanup function - t.Cleanup(func() { - os.Chdir(tmpDir) - }) +func (m *mockLayer) Digest() (v1.Hash, error) { return v1.Hash{}, nil } +func (m *mockLayer) DiffID() (v1.Hash, error) { return v1.Hash{}, nil } +func (m *mockLayer) Compressed() (io.ReadCloser, error) { return nil, nil } +func (m *mockLayer) Uncompressed() (io.ReadCloser, error) { return nil, nil } +func (m *mockLayer) Size() (int64, error) { return 0, nil } +func (m *mockLayer) MediaType() (types.MediaType, error) { return "", nil } - return &ArtifactMocks{ - Shell: mockShell, - Shims: shims, - Runtime: rt, - } -} +// mockReference provides a mock implementation of name.Reference for testing +type mockReference struct{} -// setupShims provides common shim configurations for testing. -// This function sets up standard mocks for YAML, file operations, and image creation -// that can be reused across multiple test cases to reduce duplication. -func setupShims(t *testing.T) *Shims { - t.Helper() +func (m *mockReference) Context() name.Repository { return name.Repository{} } +func (m *mockReference) Identifier() string { return "" } +func (m *mockReference) Name() string { return "" } +func (m *mockReference) String() string { return "" } +func (m *mockReference) Scope(action string) string { return "" } +// setupDefaultShims creates shims with default test configurations +func setupDefaultShims() *Shims { shims := NewShims() // Standard YAML processing mocks @@ -243,8 +227,109 @@ func setupShims(t *testing.T) *Shims { return shims } +// setupArtifactMocks creates mock components for testing the ArtifactBuilder with optional overrides +func setupArtifactMocks(t *testing.T, opts ...func(*ArtifactMocks)) *ArtifactMocks { + t.Helper() + + // Create temporary directory for test + tmpDir := t.TempDir() + + // Set up shell - default to MockShell for easier testing + mockShell := shell.NewMockShell() + + // Set up default shell behaviors + mockShell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + switch { + case strings.Contains(cmd, "git rev-parse HEAD"): + return "abc123def456", nil + case strings.Contains(cmd, "git tag --points-at HEAD"): + return "v1.0.0", nil + case strings.Contains(cmd, "git config --get remote.origin.url"): + return "https://github.com/example/repo.git", nil + case strings.Contains(cmd, "git config user.name"): + return "Test User", nil + case strings.Contains(cmd, "git config user.email"): + return "test@example.com", nil + default: + return "", nil + } + } + + // Create shims with default configurations + shims := setupDefaultShims() + + // Override specific shims for file system operations + shims.Stat = func(name string) (os.FileInfo, error) { + if name == "." { + return &mockFileInfo{name: ".", isDir: true}, nil + } + return nil, os.ErrNotExist + } + shims.Create = func(name string) (io.WriteCloser, error) { + fullPath := name + if !filepath.IsAbs(name) { + fullPath = filepath.Join(tmpDir, name) + } + + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + + return os.Create(fullPath) + } + + // Create runtime + rt := &runtime.Runtime{ + Shell: mockShell, + } + + // Create default mocks + mocks := &ArtifactMocks{ + Shell: mockShell, + Shims: shims, + Runtime: rt, + } + + // Apply any overrides + for _, opt := range opts { + opt(mocks) + } + + return mocks +} + +// createTestTarGz creates a test tar archive with the given files +func createTestTarGz(t *testing.T, files map[string][]byte) []byte { + t.Helper() + + var buf bytes.Buffer + tarWriter := tar.NewWriter(&buf) + + for path, content := range files { + header := &tar.Header{ + Name: path, + Mode: 0644, + Size: int64(len(content)), + } + + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatalf("Failed to write tar header: %v", err) + } + + if _, err := tarWriter.Write(content); err != nil { + t.Fatalf("Failed to write tar content: %v", err) + } + } + + tarWriter.Close() + + return buf.Bytes() +} + // ============================================================================= -// Test Constructor +// Test Public Methods // ============================================================================= func TestArtifactBuilder_NewArtifactBuilder(t *testing.T) { @@ -729,50 +814,139 @@ description: A test project } }) - t.Run("SkipsMetadataFileInFileLoop", func(t *testing.T) { - // Given a builder with metadata file and other files - builder, _ := setup(t) - builder.addFile("_templates/metadata.yaml", []byte("metadata content"), 0644) - builder.addFile("other.txt", []byte("other content"), 0644) - - filesWritten := make(map[string]bool) - mockTarWriter := &mockTarWriter{ - writeHeaderFunc: func(hdr *tar.Header) error { - filesWritten[hdr.Name] = true - return nil - }, - writeFunc: func(b []byte) (int, error) { - return len(b), nil - }, + t.Run("ErrorWhenBundleFails", func(t *testing.T) { + // Given a builder with failing Walk operation + builder, mocks := setup(t) + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "contexts" || name == "kustomize" || name == "terraform" { + return &mockFileInfo{name: name, isDir: true}, nil + } + return nil, os.ErrNotExist } - - builder.shims.NewTarWriter = func(w io.Writer) TarWriter { - return mockTarWriter + mocks.Shims.Walk = func(root string, walkFn filepath.WalkFunc) error { + return fmt.Errorf("walk error") } // When creating artifact _, err := builder.Write(".", "testproject:v1.0.0") - // Then no error should be returned - if err != nil { - t.Errorf("Expected success, got error: %v", err) + // Then an error should be returned + if err == nil { + t.Error("Expected error when Bundle fails") + } + if !strings.Contains(err.Error(), "failed to bundle files") { + t.Errorf("Expected bundle error, got: %v", err) } + }) - // And metadata.yaml should be written once (from the metadata generation) - // And _templates/metadata.yaml should be skipped in the file loop - if !filesWritten["metadata.yaml"] { - t.Error("Expected metadata.yaml to be written") + t.Run("ResolvesOutputPathForDirectory", func(t *testing.T) { + // Given a builder and a directory path + builder, _ := setup(t) + tmpDir := t.TempDir() + outputDir := filepath.Join(tmpDir, "output") + if err := os.MkdirAll(outputDir, 0755); err != nil { + t.Fatalf("Failed to create output directory: %v", err) } - if filesWritten["_templates/metadata.yaml"] { - t.Error("Expected _templates/metadata.yaml to be skipped in file loop") + + // When creating artifact with directory path + actualPath, err := builder.Write(outputDir, "testproject:v1.0.0") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) } - if !filesWritten["other.txt"] { - t.Error("Expected other.txt to be written") + + // And path should be resolved correctly + expectedPath := filepath.Join(outputDir, "testproject-v1.0.0.tar.gz") + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) } }) -} -func TestArtifactBuilder_Push(t *testing.T) { + t.Run("ResolvesOutputPathForFile", func(t *testing.T) { + // Given a builder and a file path + builder, _ := setup(t) + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "custom.tar.gz") + + // When creating artifact with file path + actualPath, err := builder.Write(outputFile, "testproject:v1.0.0") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And path should use the provided file path + if actualPath != outputFile { + t.Errorf("Expected path %s, got %s", outputFile, actualPath) + } + }) + + t.Run("ResolvesOutputPathForPathWithoutExtension", func(t *testing.T) { + // Given a builder and a path without extension + builder, _ := setup(t) + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "custom") + + // When creating artifact with path without extension + actualPath, err := builder.Write(outputPath, "testproject:v1.0.0") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And path should be resolved with filename appended + expectedPath := filepath.Join(tmpDir, "custom", "testproject-v1.0.0.tar.gz") + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("SkipsMetadataFileInFileLoop", func(t *testing.T) { + // Given a builder with metadata file and other files + builder, _ := setup(t) + builder.addFile("_templates/metadata.yaml", []byte("metadata content"), 0644) + builder.addFile("other.txt", []byte("other content"), 0644) + + filesWritten := make(map[string]bool) + mockTarWriter := &mockTarWriter{ + writeHeaderFunc: func(hdr *tar.Header) error { + filesWritten[hdr.Name] = true + return nil + }, + writeFunc: func(b []byte) (int, error) { + return len(b), nil + }, + } + + builder.shims.NewTarWriter = func(w io.Writer) TarWriter { + return mockTarWriter + } + + // When creating artifact + _, err := builder.Write(".", "testproject:v1.0.0") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + + // And metadata.yaml should be written once (from the metadata generation) + // And _templates/metadata.yaml should be skipped in the file loop + if !filesWritten["metadata.yaml"] { + t.Error("Expected metadata.yaml to be written") + } + if filesWritten["_templates/metadata.yaml"] { + t.Error("Expected _templates/metadata.yaml to be skipped in file loop") + } + if !filesWritten["other.txt"] { + t.Error("Expected other.txt to be written") + } + }) +} +func TestArtifactBuilder_Push(t *testing.T) { setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { t.Helper() mocks := setupArtifactMocks(t) @@ -796,7 +970,9 @@ func TestArtifactBuilder_Push(t *testing.T) { return nil } builder.addFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) - _, err := builder.Write("test.tar.gz", "") + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "test.tar.gz") + _, err := builder.Write(outputFile, "") if err != nil { t.Fatalf("Failed to create artifact: %v", err) } @@ -990,8 +1166,8 @@ func TestArtifactBuilder_Push(t *testing.T) { builder, mocks := setup(t) // Set up standard mocks - mocks.Shims = setupShims(t) - builder.shims = mocks.Shims // Update builder to use new shims + mocks.Shims = setupDefaultShims() + builder.shims = mocks.Shims // Mock that terminates early to avoid nil pointer issues mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { @@ -1014,7 +1190,7 @@ func TestArtifactBuilder_Push(t *testing.T) { builder, mocks := setup(t) // Set up standard mocks with custom manifest behavior - mocks.Shims = setupShims(t) + mocks.Shims = setupDefaultShims() builder.shims = mocks.Shims // Update builder to use new shims // Mock successful image creation but failing manifest @@ -1055,7 +1231,7 @@ func TestArtifactBuilder_Push(t *testing.T) { builder, mocks := setup(t) // Set up standard mocks with custom layer behavior - mocks.Shims = setupShims(t) + mocks.Shims = setupDefaultShims() builder.shims = mocks.Shims // Update builder to use new shims // Mock image with manifest but failing layer access @@ -1109,7 +1285,7 @@ func TestArtifactBuilder_Push(t *testing.T) { builder, mocks := setup(t) // Set up standard mocks with custom config behavior - mocks.Shims = setupShims(t) + mocks.Shims = setupDefaultShims() builder.shims = mocks.Shims // Update builder to use new shims // Mock image with empty manifest but failing config name @@ -1155,7 +1331,7 @@ func TestArtifactBuilder_Push(t *testing.T) { builder, mocks := setup(t) // Set up standard mocks with custom config file behavior - mocks.Shims = setupShims(t) + mocks.Shims = setupDefaultShims() builder.shims = mocks.Shims // Update builder to use new shims // Mock image with successful config name but failing raw config @@ -1215,7 +1391,9 @@ func TestArtifactBuilder_Push(t *testing.T) { builder.addFile("_templates/metadata.yaml", []byte(""), 0644) // When creating with tag containing multiple colons (should fail in Create method) - _, err := builder.Write("test.tar.gz", "name:version:extra") + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "test.tar.gz") + _, err := builder.Write(outputFile, "name:version:extra") // Then should get tag format error if err == nil || !strings.Contains(err.Error(), "tag must be in format 'name:version'") { @@ -1236,7 +1414,9 @@ func TestArtifactBuilder_Push(t *testing.T) { // When creating with tag having empty parts (should fail in Create method) invalidTags := []string{":version", "name:", ":"} for _, tag := range invalidTags { - _, err := builder.Write("test.tar.gz", tag) + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "test.tar.gz") + _, err := builder.Write(outputFile, tag) if err == nil || !strings.Contains(err.Error(), "tag must be in format 'name:version'") { t.Errorf("Expected tag format error for '%s', got: %v", tag, err) } @@ -1438,582 +1618,382 @@ func TestArtifactBuilder_Push(t *testing.T) { // ============================================================================= // Test Private Methods // ============================================================================= - -func TestArtifactBuilder_resolveOutputPath(t *testing.T) { +func TestArtifactBuilder_Pull(t *testing.T) { setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - t.Helper() mocks := setupArtifactMocks(t) builder := NewArtifactBuilder(mocks.Runtime) builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("GeneratesFilenameInCurrentDirectory", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) - // When resolving path for current directory - actualPath := builder.resolveOutputPath(".", "testproject", "v1.0.0") + // Set up OCI mocks + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, nil + } - // Then filename should be generated in current directory - expectedPath := "testproject-v1.0.0.tar.gz" - if actualPath != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return nil, nil } - }) - t.Run("GeneratesFilenameInSpecifiedDirectory", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) + mocks.Shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { + return []v1.Layer{&mockLayer{}}, nil + } - // When resolving path for directory without extension - actualPath := builder.resolveOutputPath("output", "testproject", "v1.0.0") + mocks.Shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { + data := []byte("test artifact data") + return io.NopCloser(strings.NewReader(string(data))), nil + } - // Then filename should be generated in that directory - expectedPath := filepath.Join("output", "testproject-v1.0.0.tar.gz") - if actualPath != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + mocks.Shims.ReadAll = func(r io.Reader) ([]byte, error) { + return []byte("test artifact data"), nil } - }) - t.Run("GeneratesFilenameWithTrailingSlash", func(t *testing.T) { - // Given a builder + return builder, mocks + } + + t.Run("EmptyList", func(t *testing.T) { + // Given an ArtifactBuilder with mocks builder, _ := setup(t) - // When resolving path with trailing slash - actualPath := builder.resolveOutputPath("output/", "testproject", "v1.0.0") + // When Pull is called with empty list + artifacts, err := builder.Pull([]string{}) - // Then filename should be generated in that directory - expectedPath := filepath.Join("output", "testproject-v1.0.0.tar.gz") - if actualPath != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) } - }) - - t.Run("UsesExplicitFilename", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) - // When resolving path with explicit filename - explicitPath := "custom-name.tar.gz" - actualPath := builder.resolveOutputPath(explicitPath, "testproject", "v1.0.0") - - // Then explicit filename should be used - if actualPath != explicitPath { - t.Errorf("Expected path %s, got %s", explicitPath, actualPath) + // And an empty map should be returned + if len(artifacts) != 0 { + t.Errorf("expected empty artifacts map, got %d items", len(artifacts)) } }) - t.Run("UsesExplicitPathWithFilename", func(t *testing.T) { - // Given a builder + t.Run("NonOCIReferences", func(t *testing.T) { + // Given an ArtifactBuilder with mocks builder, _ := setup(t) - // When resolving path with directory and filename - explicitPath := filepath.Join("output", "custom-name.tar.gz") - actualPath := builder.resolveOutputPath(explicitPath, "testproject", "v1.0.0") + // When Pull is called with non-OCI references + artifacts, err := builder.Pull([]string{ + "https://github.com/example/repo.git", + "file:///local/path", + }) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } - // Then explicit path should be used - if actualPath != explicitPath { - t.Errorf("Expected path %s, got %s", explicitPath, actualPath) + // And an empty map should be returned + if len(artifacts) != 0 { + t.Errorf("expected empty artifacts map, got %d items", len(artifacts)) } }) -} -func TestArtifactBuilder_createTarballInMemory(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - t.Helper() - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } + t.Run("SingleOCIReferenceSuccess", func(t *testing.T) { + // Given an ArtifactBuilder with mocks + builder, _ := setup(t) - t.Run("ErrorWhenTarWriterWriteHeaderFails", func(t *testing.T) { - // Given a builder with files - builder, mocks := setup(t) - builder.addFile("test.txt", []byte("content"), 0644) + // When Pull is called with one OCI reference + artifacts, err := builder.Pull([]string{"oci://registry.example.com/my-repo:v1.0.0"}) - // Mock tar writer to fail on WriteHeader - mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { - return &mockTarWriter{ - writeHeaderFunc: func(*tar.Header) error { - return fmt.Errorf("write header failed") - }, - } + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) } - // When creating tarball in memory - t.Skip("WriteTarballInMemory is no longer part of the public interface") + // And the artifacts map should contain the cached data + expectedKey := "registry.example.com/my-repo:v1.0.0" + if len(artifacts) != 1 { + t.Errorf("expected 1 artifact, got %d", len(artifacts)) + } + expectedData := []byte("test artifact data") + if string(artifacts[expectedKey]) != string(expectedData) { + t.Errorf("expected artifact data to match test data") + } }) - t.Run("ErrorWhenTarWriterWriteFails", func(t *testing.T) { - // Given a builder with files + t.Run("MultipleOCIReferencesDifferentArtifacts", func(t *testing.T) { + // Given an ArtifactBuilder with mocks builder, mocks := setup(t) - builder.addFile("test.txt", []byte("content"), 0644) - // Mock tar writer to fail on Write - mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { - return &mockTarWriter{ - writeFunc: func([]byte) (int, error) { - return 0, fmt.Errorf("write failed") - }, - } + // And mocks that return different data based on calls + downloadCallCount := 0 + mocks.Shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { + downloadCallCount++ + data := fmt.Sprintf("test artifact data %d", downloadCallCount) + return io.NopCloser(strings.NewReader(data)), nil } - // When creating tarball in memory - t.Skip("WriteTarballInMemory is no longer part of the public interface") - }) + mocks.Shims.ReadAll = func(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(r) + return data, err + } - t.Run("ErrorWhenFileHeaderWriteFails", func(t *testing.T) { - // Given a builder with files - builder, mocks := setup(t) - builder.addFile("test.txt", []byte("content"), 0644) + // When Pull is called with multiple different OCI references + artifacts, err := builder.Pull([]string{ + "oci://registry.example.com/repo1:v1.0.0", + "oci://registry.example.com/repo2:v2.0.0", + }) - headerCount := 0 - // Mock tar writer to fail on second WriteHeader (for file) - mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { - return &mockTarWriter{ - writeHeaderFunc: func(*tar.Header) error { - headerCount++ - if headerCount > 1 { - return fmt.Errorf("file header write failed") - } - return nil - }, - writeFunc: func([]byte) (int, error) { - return 100, nil // Success for metadata write - }, - } + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) } - // When creating tarball in memory - t.Skip("WriteTarballInMemory is no longer part of the public interface") - }) - - t.Run("ErrorWhenFileContentWriteFails", func(t *testing.T) { - // Given a builder with files - builder, mocks := setup(t) - builder.addFile("test.txt", []byte("content"), 0644) + // And the artifacts map should contain both entries + if len(artifacts) != 2 { + t.Errorf("expected 2 artifacts, got %d", len(artifacts)) + } - writeCount := 0 - // Mock tar writer to fail on second Write (for file content) - mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { - return &mockTarWriter{ - writeFunc: func([]byte) (int, error) { - writeCount++ - if writeCount > 1 { - return 0, fmt.Errorf("file content write failed") - } - return 100, nil // Success for metadata write - }, - } + // And both downloads should have happened + if downloadCallCount != 2 { + t.Errorf("expected 2 download calls, got %d", downloadCallCount) } - // When creating tarball in memory - t.Skip("WriteTarballInMemory is no longer part of the public interface") + // And both artifacts should be present + key1 := "registry.example.com/repo1:v1.0.0" + key2 := "registry.example.com/repo2:v2.0.0" + if _, exists := artifacts[key1]; !exists { + t.Errorf("expected artifact %s to exist", key1) + } + if _, exists := artifacts[key2]; !exists { + t.Errorf("expected artifact %s to exist", key2) + } }) - t.Run("ErrorWhenTarWriterCloseFails", func(t *testing.T) { - // Given a builder with files + t.Run("MultipleOCIReferencesSameArtifact", func(t *testing.T) { + // Given an ArtifactBuilder with mocks builder, mocks := setup(t) - builder.addFile("test.txt", []byte("content"), 0644) - // Mock tar writer to fail on Close - mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { - return &mockTarWriter{ - closeFunc: func() error { - return fmt.Errorf("tar writer close failed") - }, - } + // And mocks that track download calls + testData := "test artifact data" + downloadCallCount := 0 + mocks.Shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { + downloadCallCount++ + return io.NopCloser(strings.NewReader(testData)), nil } - // When creating tarball in memory - t.Skip("WriteTarballInMemory is no longer part of the public interface") - }) + mocks.Shims.ReadAll = func(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(r) + return data, err + } -} + // When Pull is called with duplicate OCI references + artifacts, err := builder.Pull([]string{ + "oci://registry.example.com/my-repo:v1.0.0", + "oci://registry.example.com/my-repo:v1.0.0", + }) -// ============================================================================= -// Test generateMetadataWithNameVersion -// ============================================================================= - -func TestArtifactBuilder_generateMetadataWithNameVersion(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - t.Helper() - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("SuccessWithGitProvenanceAndBuilderInfo", func(t *testing.T) { - // Given a builder with shell configured - builder, mocks := setup(t) - - // Mock successful git operations - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - switch { - case strings.Contains(cmd, "git rev-parse HEAD"): - return "abc123def456", nil - case strings.Contains(cmd, "git describe --tags --exact-match HEAD"): - return "v1.0.0", nil - case strings.Contains(cmd, "git config --get remote.origin.url"): - return "https://github.com/example/repo.git", nil - case strings.Contains(cmd, "git config --get user.name"): - return "Test User", nil - case strings.Contains(cmd, "git config --get user.email"): - return "test@example.com", nil - default: - return "", nil - } - } - - // Override YamlMarshal to actually marshal the data - mocks.Shims.YamlMarshal = func(data any) ([]byte, error) { - return yaml.Marshal(data) + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) } - input := BlueprintMetadataInput{ - Description: "Test description", - Author: "Test Author", - Tags: []string{"test", "example"}, - Homepage: "https://example.com", - License: "MIT", - CliVersion: ">=1.0.0", + // And the artifacts map should contain only one entry (deduplicated) + if len(artifacts) != 1 { + t.Errorf("expected 1 artifact, got %d", len(artifacts)) } - // When generating metadata - metadata, err := builder.generateMetadataWithNameVersion(input, "testapp", "1.0.0") - - // Then should succeed - if err != nil { - t.Errorf("Expected success, got error: %v", err) - } - if metadata == nil { - t.Error("Expected metadata to be generated") + // And the download should only happen once (caching works) + if downloadCallCount != 1 { + t.Errorf("expected 1 download call, got %d", downloadCallCount) } - // And cliVersion should be preserved in generated metadata - var generatedMetadata BlueprintMetadata - if err := yaml.Unmarshal(metadata, &generatedMetadata); err != nil { - t.Fatalf("Failed to unmarshal generated metadata: %v", err) - } - if generatedMetadata.CliVersion != ">=1.0.0" { - t.Errorf("Expected cliVersion to be '>=1.0.0', got '%s'", generatedMetadata.CliVersion) + expectedKey := "registry.example.com/my-repo:v1.0.0" + if string(artifacts[expectedKey]) != testData { + t.Errorf("expected artifact data to match test data") } }) - t.Run("SuccessWithGitProvenanceFailure", func(t *testing.T) { - // Given a builder with shell configured to fail git operations - builder, mocks := setup(t) + t.Run("ErrorParsingOCIReference", func(t *testing.T) { + // Given an ArtifactBuilder with mocks + builder, _ := setup(t) - // Mock git operations to fail - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - return "", fmt.Errorf("git command failed") - } + // When Pull is called with invalid OCI reference (missing repository part) + _, err := builder.Pull([]string{"oci://registry.example.com:v1.0.0"}) - input := BlueprintMetadataInput{ - Description: "Test description", + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") } - // When generating metadata - metadata, err := builder.generateMetadataWithNameVersion(input, "testapp", "1.0.0") - - // Then should succeed with empty git provenance - if err != nil { - t.Errorf("Expected success despite git failures, got error: %v", err) - } - if metadata == nil { - t.Error("Expected metadata to be generated") + // And the error should mention parsing + if !strings.Contains(err.Error(), "failed to parse OCI reference") { + t.Errorf("expected parse error, got %v", err) } }) - t.Run("ErrorWhenYamlMarshalFails", func(t *testing.T) { - // Given a builder with failing YAML marshal + t.Run("ErrorDownloadingArtifact", func(t *testing.T) { + // Given an ArtifactBuilder with mocks that fail at download builder, mocks := setup(t) - mocks.Shims.YamlMarshal = func(data any) ([]byte, error) { - return nil, fmt.Errorf("yaml marshal failed") + + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, fmt.Errorf("download error") } - input := BlueprintMetadataInput{} + // When Pull is called with valid OCI reference but download fails + _, err := builder.Pull([]string{"oci://registry.example.com/my-repo:v1.0.0"}) - // When generating metadata - _, err := builder.generateMetadataWithNameVersion(input, "testapp", "1.0.0") + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } - // Then should get marshal error - if err == nil || !strings.Contains(err.Error(), "yaml marshal failed") { - t.Errorf("Expected yaml marshal error, got: %v", err) + // And the error should mention download failure + if !strings.Contains(err.Error(), "failed to download OCI artifact") { + t.Errorf("expected download error, got %v", err) } }) -} -func TestArtifactBuilder_getGitProvenance(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - t.Helper() + t.Run("CachingPreventsRedundantDownloads", func(t *testing.T) { + // Given an ArtifactBuilder with mocked dependencies mocks := setupArtifactMocks(t) builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("SuccessWithAllGitInfo", func(t *testing.T) { - // Given a builder with successful git operations - builder, mocks := setup(t) + _ = shell.NewMockShell() - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - switch { - case strings.Contains(cmd, "git rev-parse HEAD"): - return " abc123def456 ", nil // With whitespace to test trimming - case strings.Contains(cmd, "git describe --tags --exact-match HEAD"): - return " v1.0.0 ", nil // With whitespace to test trimming - case strings.Contains(cmd, "git config --get remote.origin.url"): - return " https://github.com/example/repo.git ", nil // With whitespace - default: - return "", fmt.Errorf("unexpected command: %s", cmd) - } + // And download counter to track calls + downloadCount := 0 + builder.shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil } - - // When getting git provenance - provenance, err := builder.getGitProvenance() - - // Then should succeed with trimmed values - if err != nil { - t.Errorf("Expected success, got error: %v", err) + builder.shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return &mockImage{}, nil } - if provenance.CommitSHA != "abc123def456" { - t.Errorf("Expected commit SHA 'abc123def456', got '%s'", provenance.CommitSHA) + builder.shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { + return []v1.Layer{&mockLayer{}}, nil } - if provenance.Tag != "v1.0.0" { - t.Errorf("Expected tag 'v1.0.0', got '%s'", provenance.Tag) + builder.shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { + downloadCount++ + data := []byte("test artifact data") + return io.NopCloser(bytes.NewReader(data)), nil } - if provenance.RemoteURL != "https://github.com/example/repo.git" { - t.Errorf("Expected remote URL 'https://github.com/example/repo.git', got '%s'", provenance.RemoteURL) + builder.shims.ReadAll = func(r io.Reader) ([]byte, error) { + return io.ReadAll(r) } - }) - - t.Run("ErrorWhenCommitSHAFails", func(t *testing.T) { - // Given a builder with failing commit SHA command - builder, mocks := setup(t) - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - if strings.Contains(cmd, "git rev-parse HEAD") { - return "", fmt.Errorf("not a git repository") - } - return "", nil + ociRefs := []string{ + "oci://registry.example.com/my-repo:v1.0.0", + "oci://registry.example.com/my-repo:v1.0.0", // Same artifact - should be cached } - // When getting git provenance - _, err := builder.getGitProvenance() + // When Pull is called the first time + artifacts1, err := builder.Pull(ociRefs) - // Then should get commit SHA error - if err == nil || !strings.Contains(err.Error(), "failed to get commit SHA") { - t.Errorf("Expected commit SHA error, got: %v", err) + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) } - }) - t.Run("SuccessWithMissingTag", func(t *testing.T) { - // Given a builder where tag command fails but others succeed - builder, mocks := setup(t) + // And one artifact should be returned + if len(artifacts1) != 1 { + t.Errorf("expected 1 artifact, got %d", len(artifacts1)) + } - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - switch { - case strings.Contains(cmd, "git rev-parse HEAD"): - return "abc123def456", nil - case strings.Contains(cmd, "git describe --tags --exact-match HEAD"): - return "", fmt.Errorf("no tag found") // Tag command fails - case strings.Contains(cmd, "git config --get remote.origin.url"): - return "https://github.com/example/repo.git", nil - default: - return "", fmt.Errorf("unexpected command: %s", cmd) - } + // And download should have happened once + if downloadCount != 1 { + t.Errorf("expected 1 download call, got %d", downloadCount) } - // When getting git provenance - provenance, err := builder.getGitProvenance() + // When Pull is called again with the same artifacts + artifacts2, err := builder.Pull(ociRefs) - // Then should succeed with empty tag + // Then no error should occur if err != nil { - t.Errorf("Expected success, got error: %v", err) - } - if provenance.CommitSHA != "abc123def456" { - t.Errorf("Expected commit SHA 'abc123def456', got '%s'", provenance.CommitSHA) - } - if provenance.Tag != "" { - t.Errorf("Expected empty tag, got '%s'", provenance.Tag) - } - if provenance.RemoteURL != "https://github.com/example/repo.git" { - t.Errorf("Expected remote URL, got '%s'", provenance.RemoteURL) + t.Errorf("expected no error, got %v", err) } - }) - - t.Run("SuccessWithMissingRemoteURL", func(t *testing.T) { - // Given a builder where remote URL command fails - builder, mocks := setup(t) - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - switch { - case strings.Contains(cmd, "git rev-parse HEAD"): - return "abc123def456", nil - case strings.Contains(cmd, "git describe --tags --exact-match HEAD"): - return "v1.0.0", nil - case strings.Contains(cmd, "git config --get remote.origin.url"): - return "", fmt.Errorf("no remote configured") // Remote URL fails - default: - return "", fmt.Errorf("unexpected command: %s", cmd) - } + // And one artifact should be returned + if len(artifacts2) != 1 { + t.Errorf("expected 1 artifact, got %d", len(artifacts2)) } - // When getting git provenance - provenance, err := builder.getGitProvenance() - - // Then should succeed with empty remote URL - if err != nil { - t.Errorf("Expected success, got error: %v", err) - } - if provenance.CommitSHA != "abc123def456" { - t.Errorf("Expected commit SHA 'abc123def456', got '%s'", provenance.CommitSHA) - } - if provenance.Tag != "v1.0.0" { - t.Errorf("Expected tag 'v1.0.0', got '%s'", provenance.Tag) + // And download should NOT have happened again (still 1) + if downloadCount != 1 { + t.Errorf("expected 1 download call total (cached), got %d", downloadCount) } - if provenance.RemoteURL != "" { - t.Errorf("Expected empty remote URL, got '%s'", provenance.RemoteURL) + + // And the artifact data should be identical + expectedKey := "registry.example.com/my-repo:v1.0.0" + if !bytes.Equal(artifacts1[expectedKey], artifacts2[expectedKey]) { + t.Errorf("cached artifact data should be identical") } }) -} -func TestArtifactBuilder_getBuilderInfo(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - t.Helper() + t.Run("CachingWorksWithMixedNewAndCachedArtifacts", func(t *testing.T) { + // Given an ArtifactBuilder with mocked dependencies mocks := setupArtifactMocks(t) builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("SuccessWithUserAndEmail", func(t *testing.T) { - // Given a builder with configured git user info - builder, mocks := setup(t) + _ = shell.NewMockShell() - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - switch { - case strings.Contains(cmd, "git config --get user.name"): - return " Test User ", nil // With whitespace to test trimming - case strings.Contains(cmd, "git config --get user.email"): - return " test@example.com ", nil // With whitespace to test trimming - default: - return "", fmt.Errorf("unexpected command: %s", cmd) - } + // And download counter to track calls + downloadCount := 0 + builder.shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil } - - // When getting builder info - builderInfo, err := builder.getBuilderInfo() - - // Then should succeed with trimmed values - if err != nil { - t.Errorf("Expected success, got error: %v", err) + builder.shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return &mockImage{}, nil } - if builderInfo.User != "Test User" { - t.Errorf("Expected user 'Test User', got '%s'", builderInfo.User) + builder.shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { + return []v1.Layer{&mockLayer{}}, nil } - if builderInfo.Email != "test@example.com" { - t.Errorf("Expected email 'test@example.com', got '%s'", builderInfo.Email) + builder.shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { + downloadCount++ + data := []byte(fmt.Sprintf("test artifact data %d", downloadCount)) + return io.NopCloser(bytes.NewReader(data)), nil } - }) - - t.Run("SuccessWithMissingUserName", func(t *testing.T) { - // Given a builder where user name is not configured - builder, mocks := setup(t) - - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - switch { - case strings.Contains(cmd, "git config --get user.name"): - return "", fmt.Errorf("user.name not configured") - case strings.Contains(cmd, "git config --get user.email"): - return "test@example.com", nil - default: - return "", fmt.Errorf("unexpected command: %s", cmd) - } + builder.shims.ReadAll = func(r io.Reader) ([]byte, error) { + return io.ReadAll(r) } - // When getting builder info - builderInfo, err := builder.getBuilderInfo() - - // Then should succeed with empty user name + // When Pull is called with one artifact + artifacts1, err := builder.Pull([]string{"oci://registry.example.com/repo1:v1.0.0"}) if err != nil { - t.Errorf("Expected success, got error: %v", err) - } - if builderInfo.User != "" { - t.Errorf("Expected empty user, got '%s'", builderInfo.User) - } - if builderInfo.Email != "test@example.com" { - t.Errorf("Expected email 'test@example.com', got '%s'", builderInfo.Email) + t.Fatalf("failed to pull first artifact: %v", err) } - }) - t.Run("SuccessWithMissingEmail", func(t *testing.T) { - // Given a builder where email is not configured - builder, mocks := setup(t) - - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - switch { - case strings.Contains(cmd, "git config --get user.name"): - return "Test User", nil - case strings.Contains(cmd, "git config --get user.email"): - return "", fmt.Errorf("user.email not configured") - default: - return "", fmt.Errorf("unexpected command: %s", cmd) - } + // Then one download should have occurred + if downloadCount != 1 { + t.Errorf("expected 1 download call, got %d", downloadCount) } - // When getting builder info - builderInfo, err := builder.getBuilderInfo() + // When Pull is called with the cached artifact plus a new one + artifacts2, err := builder.Pull([]string{ + "oci://registry.example.com/repo1:v1.0.0", // Cached + "oci://registry.example.com/repo2:v2.0.0", // New + }) - // Then should succeed with empty email + // Then no error should occur if err != nil { - t.Errorf("Expected success, got error: %v", err) - } - if builderInfo.User != "Test User" { - t.Errorf("Expected user 'Test User', got '%s'", builderInfo.User) - } - if builderInfo.Email != "" { - t.Errorf("Expected empty email, got '%s'", builderInfo.Email) + t.Errorf("expected no error, got %v", err) } - }) - - t.Run("SuccessWithBothMissing", func(t *testing.T) { - // Given a builder where both user and email are not configured - builder, mocks := setup(t) - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - return "", fmt.Errorf("git config not found") + // And two artifacts should be returned + if len(artifacts2) != 2 { + t.Errorf("expected 2 artifacts, got %d", len(artifacts2)) } - // When getting builder info - builderInfo, err := builder.getBuilderInfo() + // And only one additional download should have occurred (total 2) + if downloadCount != 2 { + t.Errorf("expected 2 download calls total, got %d", downloadCount) + } - // Then should succeed with empty values - if err != nil { - t.Errorf("Expected success, got error: %v", err) + // And both cached and new artifacts should be present + key1 := "registry.example.com/repo1:v1.0.0" + key2 := "registry.example.com/repo2:v2.0.0" + if _, exists := artifacts2[key1]; !exists { + t.Errorf("expected cached artifact %s to exist", key1) } - if builderInfo.User != "" { - t.Errorf("Expected empty user, got '%s'", builderInfo.User) + if _, exists := artifacts2[key2]; !exists { + t.Errorf("expected new artifact %s to exist", key2) } - if builderInfo.Email != "" { - t.Errorf("Expected empty email, got '%s'", builderInfo.Email) + + // And the cached artifact should be identical to the first call + if !bytes.Equal(artifacts1[key1], artifacts2[key1]) { + t.Errorf("cached artifact data should be identical") } }) } -func TestArtifactBuilder_createOCIArtifactImage(t *testing.T) { +func TestArtifactBuilder_Bundle(t *testing.T) { setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { t.Helper() mocks := setupArtifactMocks(t) @@ -2022,1845 +2002,31 @@ func TestArtifactBuilder_createOCIArtifactImage(t *testing.T) { return builder, mocks } - t.Run("ErrorWhenAppendLayersFails", func(t *testing.T) { - // Given a builder with failing AppendLayers - _, mocks := setup(t) - - // Mock git provenance to succeed but AppendLayers to fail - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - if strings.Contains(cmd, "git rev-parse HEAD") { - return "abc123", nil - } - return "", nil - } - - mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { - return nil, fmt.Errorf("append layers failed") - } - - // When creating OCI artifact image - t.Skip("WriteOCIArtifactImage is no longer part of the public interface") - }) + t.Run("SuccessWithAllDirectories", func(t *testing.T) { + // Given a builder with mock directories and files + builder, mocks := setup(t) - t.Run("SuccessWithValidLayer", func(t *testing.T) { - // Given a builder with successful shim operations - _, mocks := setup(t) - - // Mock git provenance to return test data - expectedCommitSHA := "abc123def456" - expectedRemoteURL := "https://github.com/user/repo.git" - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - if strings.Contains(cmd, "git rev-parse HEAD") { - return expectedCommitSHA, nil - } - if strings.Contains(cmd, "git config --get remote.origin.url") { - return expectedRemoteURL, nil + // Mock directory structure + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "contexts" || name == "kustomize" || name == "terraform" { + return &mockFileInfo{name: name, isDir: true}, nil } - return "", nil + return nil, os.ErrNotExist } - // Mock successful image creation - mockImage := &mockImage{} - mocks.Shims.EmptyImage = func() v1.Image { return mockImage } - mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { - return mockImage, nil - } - mocks.Shims.ConfigFile = func(img v1.Image, cfg *v1.ConfigFile) (v1.Image, error) { - // Verify config file has expected properties - if cfg.Architecture != "amd64" { - return nil, fmt.Errorf("expected amd64 architecture, got %s", cfg.Architecture) - } - if cfg.OS != "linux" { - return nil, fmt.Errorf("expected linux OS, got %s", cfg.OS) - } - if cfg.Config.Labels["org.opencontainers.image.title"] != "test-repo" { - return nil, fmt.Errorf("expected title label to be test-repo") - } - return mockImage, nil - } - mocks.Shims.MediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } - mocks.Shims.ConfigMediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } - - // Capture annotations to verify revision and source are set correctly - mocks.Shims.Annotations = func(img v1.Image, anns map[string]string) v1.Image { - return mockImage - } - - // When creating OCI artifact image - t.Skip("WriteOCIArtifactImage is no longer part of the public interface") - }) - - t.Run("SuccessWithGitProvenanceFallback", func(t *testing.T) { - // Given a builder where git provenance fails - _, mocks := setup(t) - - // Mock git provenance to fail - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - return "", fmt.Errorf("git command failed") - } - - // Mock successful image creation - mockImage := &mockImage{} - mocks.Shims.EmptyImage = func() v1.Image { return mockImage } - mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { - return mockImage, nil - } - mocks.Shims.ConfigFile = func(img v1.Image, cfg *v1.ConfigFile) (v1.Image, error) { - return mockImage, nil - } - mocks.Shims.MediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } - mocks.Shims.ConfigMediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } - - // Capture annotations to verify fallback revision and source - mocks.Shims.Annotations = func(img v1.Image, anns map[string]string) v1.Image { - return mockImage - } - - // When creating OCI artifact image - t.Skip("WriteOCIArtifactImage is no longer part of the public interface") - }) - - t.Run("SuccessWithEmptyCommitSHA", func(t *testing.T) { - // Given a builder where git returns empty commit SHA but valid remote URL - _, mocks := setup(t) - - expectedRemoteURL := "https://github.com/user/empty-sha-repo.git" - // Mock git provenance to return empty commit SHA but valid remote URL - mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { - cmd := strings.Join(append([]string{command}, args...), " ") - if strings.Contains(cmd, "git rev-parse HEAD") { - return " ", nil // whitespace only - } - if strings.Contains(cmd, "git config --get remote.origin.url") { - return expectedRemoteURL, nil - } - return "", nil - } - - // Mock successful image creation - mockImage := &mockImage{} - mocks.Shims.EmptyImage = func() v1.Image { return mockImage } - mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { - return mockImage, nil - } - mocks.Shims.ConfigFile = func(img v1.Image, cfg *v1.ConfigFile) (v1.Image, error) { - return mockImage, nil - } - mocks.Shims.MediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } - mocks.Shims.ConfigMediaType = func(img v1.Image, mt types.MediaType) v1.Image { return mockImage } - - // Capture annotations to verify fallback revision but valid source - mocks.Shims.Annotations = func(img v1.Image, anns map[string]string) v1.Image { - return mockImage - } - - // When creating OCI artifact image - t.Skip("WriteOCIArtifactImage is no longer part of the public interface") - }) -} - -// ============================================================================= -// Test Helpers -// ============================================================================= - -// mockImage provides a mock implementation of v1.Image for testing -type mockImage struct{} - -func (m *mockImage) Layers() ([]v1.Layer, error) { return nil, nil } -func (m *mockImage) MediaType() (types.MediaType, error) { return "", nil } -func (m *mockImage) Size() (int64, error) { return 0, nil } -func (m *mockImage) ConfigName() (v1.Hash, error) { return v1.Hash{}, nil } -func (m *mockImage) ConfigFile() (*v1.ConfigFile, error) { return nil, nil } -func (m *mockImage) RawConfigFile() ([]byte, error) { return nil, nil } -func (m *mockImage) Digest() (v1.Hash, error) { return v1.Hash{}, nil } -func (m *mockImage) Manifest() (*v1.Manifest, error) { return nil, nil } -func (m *mockImage) RawManifest() ([]byte, error) { return nil, nil } -func (m *mockImage) LayerByDigest(v1.Hash) (v1.Layer, error) { return nil, nil } -func (m *mockImage) LayerByDiffID(v1.Hash) (v1.Layer, error) { return nil, nil } - -// Enhanced mock image with configurable behavior for testing different scenarios -type mockImageWithManifest struct { - manifestFunc func() (*v1.Manifest, error) - layerByDigestFunc func(v1.Hash) (v1.Layer, error) - configNameFunc func() (v1.Hash, error) - rawConfigFileFunc func() ([]byte, error) -} - -func (m *mockImageWithManifest) Layers() ([]v1.Layer, error) { return nil, nil } -func (m *mockImageWithManifest) MediaType() (types.MediaType, error) { return "", nil } -func (m *mockImageWithManifest) Size() (int64, error) { return 0, nil } -func (m *mockImageWithManifest) ConfigFile() (*v1.ConfigFile, error) { return nil, nil } -func (m *mockImageWithManifest) Digest() (v1.Hash, error) { return v1.Hash{}, nil } -func (m *mockImageWithManifest) RawManifest() ([]byte, error) { return nil, nil } -func (m *mockImageWithManifest) LayerByDiffID(v1.Hash) (v1.Layer, error) { return nil, nil } - -func (m *mockImageWithManifest) Manifest() (*v1.Manifest, error) { - if m.manifestFunc != nil { - return m.manifestFunc() - } - mockImg := &mockImage{} - return mockImg.Manifest() -} - -func (m *mockImageWithManifest) LayerByDigest(hash v1.Hash) (v1.Layer, error) { - if m.layerByDigestFunc != nil { - return m.layerByDigestFunc(hash) - } - mockImg := &mockImage{} - return mockImg.LayerByDigest(hash) -} - -func (m *mockImageWithManifest) ConfigName() (v1.Hash, error) { - if m.configNameFunc != nil { - return m.configNameFunc() - } - mockImg := &mockImage{} - return mockImg.ConfigName() -} - -func (m *mockImageWithManifest) RawConfigFile() ([]byte, error) { - if m.rawConfigFileFunc != nil { - return m.rawConfigFileFunc() - } - mockImg := &mockImage{} - return mockImg.RawConfigFile() -} - -// Mock layer for testing -type mockLayer struct{} - -func (m *mockLayer) Digest() (v1.Hash, error) { return v1.Hash{}, nil } -func (m *mockLayer) DiffID() (v1.Hash, error) { return v1.Hash{}, nil } -func (m *mockLayer) Compressed() (io.ReadCloser, error) { return nil, nil } -func (m *mockLayer) Uncompressed() (io.ReadCloser, error) { return nil, nil } -func (m *mockLayer) Size() (int64, error) { return 0, nil } -func (m *mockLayer) MediaType() (types.MediaType, error) { return "", nil } - -func TestArtifactBuilder_parseOCIRef(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("ValidOCIReference", func(t *testing.T) { - // Given an ArtifactBuilder - builder, _ := setup(t) - - // When parseOCIRef is called with valid OCI reference - registry, repository, tag, err := builder.parseOCIRef("oci://registry.example.com/my-repo:v1.0.0") - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And the components should be parsed correctly - if registry != "registry.example.com" { - t.Errorf("expected registry 'registry.example.com', got %s", registry) - } - if repository != "my-repo" { - t.Errorf("expected repository 'my-repo', got %s", repository) - } - if tag != "v1.0.0" { - t.Errorf("expected tag 'v1.0.0', got %s", tag) - } - }) - - t.Run("InvalidOCIPrefix", func(t *testing.T) { - // Given an ArtifactBuilder - builder, _ := setup(t) - - // When parseOCIRef is called with invalid prefix - _, _, _, err := builder.parseOCIRef("https://registry.example.com/my-repo:v1.0.0") - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should contain the expected message - expectedError := "invalid OCI reference format: https://registry.example.com/my-repo:v1.0.0" - if err.Error() != expectedError { - t.Errorf("expected error %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("MissingTag", func(t *testing.T) { - // Given an ArtifactBuilder - builder, _ := setup(t) - - // When parseOCIRef is called with missing tag - _, _, _, err := builder.parseOCIRef("oci://registry.example.com/my-repo") - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should contain the expected message - expectedError := "invalid OCI reference format, expected registry/repository:tag: oci://registry.example.com/my-repo" - if err.Error() != expectedError { - t.Errorf("expected error %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("MissingRepository", func(t *testing.T) { - // Given an ArtifactBuilder - builder, _ := setup(t) - - // When parseOCIRef is called with missing repository - _, _, _, err := builder.parseOCIRef("oci://registry.example.com:v1.0.0") - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should contain the expected message - expectedError := "invalid OCI reference format, expected registry/repository:tag: oci://registry.example.com:v1.0.0" - if err.Error() != expectedError { - t.Errorf("expected error %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("NestedRepositoryPath", func(t *testing.T) { - // Given an ArtifactBuilder - builder, _ := setup(t) - - // When parseOCIRef is called with nested repository path - registry, repository, tag, err := builder.parseOCIRef("oci://registry.example.com/organization/my-repo:v1.0.0") - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And the components should be parsed correctly - if registry != "registry.example.com" { - t.Errorf("expected registry 'registry.example.com', got %s", registry) - } - if repository != "organization/my-repo" { - t.Errorf("expected repository 'organization/my-repo', got %s", repository) - } - if tag != "v1.0.0" { - t.Errorf("expected tag 'v1.0.0', got %s", tag) - } - }) -} - -func TestArtifactBuilder_downloadOCIArtifact(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("ParseReferenceSuccess", func(t *testing.T) { - // Given an ArtifactBuilder with successful parse but failing remote - builder, mocks := setup(t) - - mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { - return nil, nil // Success - } - - mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { - return nil, fmt.Errorf("remote image error") // Fail at next step - } - - // When downloadOCIArtifact is called - _, err := builder.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") - - // Then it should fail at remote image step - if err == nil { - t.Error("expected error for remote image failure") - } - if !strings.Contains(err.Error(), "failed to get image") { - t.Errorf("expected remote image error, got %v", err) - } - }) - - t.Run("ParseReferenceError", func(t *testing.T) { - // Given an ArtifactBuilder with parse reference error - builder, mocks := setup(t) - - mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { - return nil, fmt.Errorf("parse error") - } - - // When downloadOCIArtifact is called - _, err := builder.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") - - // Then it should return parse reference error - if err == nil { - t.Error("expected error for parse reference failure") - } - if !strings.Contains(err.Error(), "failed to parse reference") { - t.Errorf("expected parse reference error, got %v", err) - } - }) - - t.Run("RemoteImageError", func(t *testing.T) { - // Given an ArtifactBuilder with remote image error - builder, mocks := setup(t) - - mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { - return nil, nil - } - - mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { - return nil, fmt.Errorf("remote error") - } - - // When downloadOCIArtifact is called - _, err := builder.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") - - // Then it should return remote image error - if err == nil { - t.Error("expected error for remote image failure") - } - if !strings.Contains(err.Error(), "failed to get image") { - t.Errorf("expected remote image error, got %v", err) - } - }) - - t.Run("ImageLayersError", func(t *testing.T) { - // Given an ArtifactBuilder with image layers error - builder, mocks := setup(t) - - mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { - return nil, nil - } - - mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { - return nil, nil - } - - mocks.Shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { - return nil, fmt.Errorf("layers error") - } - - // When downloadOCIArtifact is called - _, err := builder.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") - - // Then it should return image layers error - if err == nil { - t.Error("expected error for image layers failure") - } - if !strings.Contains(err.Error(), "failed to get image layers") { - t.Errorf("expected image layers error, got %v", err) - } - }) -} - -func TestArtifactBuilder_Pull(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - - // Set up OCI mocks - mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { - return nil, nil - } - - mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { - return nil, nil - } - - mocks.Shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { - return []v1.Layer{&mockLayer{}}, nil - } - - mocks.Shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { - data := []byte("test artifact data") - return io.NopCloser(strings.NewReader(string(data))), nil - } - - mocks.Shims.ReadAll = func(r io.Reader) ([]byte, error) { - return []byte("test artifact data"), nil - } - - return builder, mocks - } - - t.Run("EmptyList", func(t *testing.T) { - // Given an ArtifactBuilder with mocks - builder, _ := setup(t) - - // When Pull is called with empty list - artifacts, err := builder.Pull([]string{}) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And an empty map should be returned - if len(artifacts) != 0 { - t.Errorf("expected empty artifacts map, got %d items", len(artifacts)) - } - }) - - t.Run("NonOCIReferences", func(t *testing.T) { - // Given an ArtifactBuilder with mocks - builder, _ := setup(t) - - // When Pull is called with non-OCI references - artifacts, err := builder.Pull([]string{ - "https://github.com/example/repo.git", - "file:///local/path", - }) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And an empty map should be returned - if len(artifacts) != 0 { - t.Errorf("expected empty artifacts map, got %d items", len(artifacts)) - } - }) - - t.Run("SingleOCIReferenceSuccess", func(t *testing.T) { - // Given an ArtifactBuilder with mocks - builder, _ := setup(t) - - // When Pull is called with one OCI reference - artifacts, err := builder.Pull([]string{"oci://registry.example.com/my-repo:v1.0.0"}) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And the artifacts map should contain the cached data - expectedKey := "registry.example.com/my-repo:v1.0.0" - if len(artifacts) != 1 { - t.Errorf("expected 1 artifact, got %d", len(artifacts)) - } - expectedData := []byte("test artifact data") - if string(artifacts[expectedKey]) != string(expectedData) { - t.Errorf("expected artifact data to match test data") - } - }) - - t.Run("MultipleOCIReferencesDifferentArtifacts", func(t *testing.T) { - // Given an ArtifactBuilder with mocks - builder, mocks := setup(t) - - // And mocks that return different data based on calls - downloadCallCount := 0 - mocks.Shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { - downloadCallCount++ - data := fmt.Sprintf("test artifact data %d", downloadCallCount) - return io.NopCloser(strings.NewReader(data)), nil - } - - mocks.Shims.ReadAll = func(r io.Reader) ([]byte, error) { - data, err := io.ReadAll(r) - return data, err - } - - // When Pull is called with multiple different OCI references - artifacts, err := builder.Pull([]string{ - "oci://registry.example.com/repo1:v1.0.0", - "oci://registry.example.com/repo2:v2.0.0", - }) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And the artifacts map should contain both entries - if len(artifacts) != 2 { - t.Errorf("expected 2 artifacts, got %d", len(artifacts)) - } - - // And both downloads should have happened - if downloadCallCount != 2 { - t.Errorf("expected 2 download calls, got %d", downloadCallCount) - } - - // And both artifacts should be present - key1 := "registry.example.com/repo1:v1.0.0" - key2 := "registry.example.com/repo2:v2.0.0" - if _, exists := artifacts[key1]; !exists { - t.Errorf("expected artifact %s to exist", key1) - } - if _, exists := artifacts[key2]; !exists { - t.Errorf("expected artifact %s to exist", key2) - } - }) - - t.Run("MultipleOCIReferencesSameArtifact", func(t *testing.T) { - // Given an ArtifactBuilder with mocks - builder, mocks := setup(t) - - // And mocks that track download calls - testData := "test artifact data" - downloadCallCount := 0 - mocks.Shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { - downloadCallCount++ - return io.NopCloser(strings.NewReader(testData)), nil - } - - mocks.Shims.ReadAll = func(r io.Reader) ([]byte, error) { - data, err := io.ReadAll(r) - return data, err - } - - // When Pull is called with duplicate OCI references - artifacts, err := builder.Pull([]string{ - "oci://registry.example.com/my-repo:v1.0.0", - "oci://registry.example.com/my-repo:v1.0.0", - }) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And the artifacts map should contain only one entry (deduplicated) - if len(artifacts) != 1 { - t.Errorf("expected 1 artifact, got %d", len(artifacts)) - } - - // And the download should only happen once (caching works) - if downloadCallCount != 1 { - t.Errorf("expected 1 download call, got %d", downloadCallCount) - } - - expectedKey := "registry.example.com/my-repo:v1.0.0" - if string(artifacts[expectedKey]) != testData { - t.Errorf("expected artifact data to match test data") - } - }) - - t.Run("ErrorParsingOCIReference", func(t *testing.T) { - // Given an ArtifactBuilder with mocks - builder, _ := setup(t) - - // When Pull is called with invalid OCI reference (missing repository part) - _, err := builder.Pull([]string{"oci://registry.example.com:v1.0.0"}) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should mention parsing - if !strings.Contains(err.Error(), "failed to parse OCI reference") { - t.Errorf("expected parse error, got %v", err) - } - }) - - t.Run("ErrorDownloadingArtifact", func(t *testing.T) { - // Given an ArtifactBuilder with mocks that fail at download - builder, mocks := setup(t) - - mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { - return nil, fmt.Errorf("download error") - } - - // When Pull is called with valid OCI reference but download fails - _, err := builder.Pull([]string{"oci://registry.example.com/my-repo:v1.0.0"}) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should mention download failure - if !strings.Contains(err.Error(), "failed to download OCI artifact") { - t.Errorf("expected download error, got %v", err) - } - }) - - t.Run("CachingPreventsRedundantDownloads", func(t *testing.T) { - // Given an ArtifactBuilder with mocked dependencies - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - _ = shell.NewMockShell() - - // And download counter to track calls - downloadCount := 0 - builder.shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { - return &mockReference{}, nil - } - builder.shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { - return &mockImage{}, nil - } - builder.shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { - return []v1.Layer{&mockLayer{}}, nil - } - builder.shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { - downloadCount++ - data := []byte("test artifact data") - return io.NopCloser(bytes.NewReader(data)), nil - } - builder.shims.ReadAll = func(r io.Reader) ([]byte, error) { - return io.ReadAll(r) - } - - ociRefs := []string{ - "oci://registry.example.com/my-repo:v1.0.0", - "oci://registry.example.com/my-repo:v1.0.0", // Same artifact - should be cached - } - - // When Pull is called the first time - artifacts1, err := builder.Pull(ociRefs) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And one artifact should be returned - if len(artifacts1) != 1 { - t.Errorf("expected 1 artifact, got %d", len(artifacts1)) - } - - // And download should have happened once - if downloadCount != 1 { - t.Errorf("expected 1 download call, got %d", downloadCount) - } - - // When Pull is called again with the same artifacts - artifacts2, err := builder.Pull(ociRefs) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And one artifact should be returned - if len(artifacts2) != 1 { - t.Errorf("expected 1 artifact, got %d", len(artifacts2)) - } - - // And download should NOT have happened again (still 1) - if downloadCount != 1 { - t.Errorf("expected 1 download call total (cached), got %d", downloadCount) - } - - // And the artifact data should be identical - expectedKey := "registry.example.com/my-repo:v1.0.0" - if !bytes.Equal(artifacts1[expectedKey], artifacts2[expectedKey]) { - t.Errorf("cached artifact data should be identical") - } - }) - - t.Run("CachingWorksWithMixedNewAndCachedArtifacts", func(t *testing.T) { - // Given an ArtifactBuilder with mocked dependencies - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - _ = shell.NewMockShell() - - // And download counter to track calls - downloadCount := 0 - builder.shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { - return &mockReference{}, nil - } - builder.shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { - return &mockImage{}, nil - } - builder.shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { - return []v1.Layer{&mockLayer{}}, nil - } - builder.shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { - downloadCount++ - data := []byte(fmt.Sprintf("test artifact data %d", downloadCount)) - return io.NopCloser(bytes.NewReader(data)), nil - } - builder.shims.ReadAll = func(r io.Reader) ([]byte, error) { - return io.ReadAll(r) - } - - // When Pull is called with one artifact - artifacts1, err := builder.Pull([]string{"oci://registry.example.com/repo1:v1.0.0"}) - if err != nil { - t.Fatalf("failed to pull first artifact: %v", err) - } - - // Then one download should have occurred - if downloadCount != 1 { - t.Errorf("expected 1 download call, got %d", downloadCount) - } - - // When Pull is called with the cached artifact plus a new one - artifacts2, err := builder.Pull([]string{ - "oci://registry.example.com/repo1:v1.0.0", // Cached - "oci://registry.example.com/repo2:v2.0.0", // New - }) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And two artifacts should be returned - if len(artifacts2) != 2 { - t.Errorf("expected 2 artifacts, got %d", len(artifacts2)) - } - - // And only one additional download should have occurred (total 2) - if downloadCount != 2 { - t.Errorf("expected 2 download calls total, got %d", downloadCount) - } - - // And both cached and new artifacts should be present - key1 := "registry.example.com/repo1:v1.0.0" - key2 := "registry.example.com/repo2:v2.0.0" - if _, exists := artifacts2[key1]; !exists { - t.Errorf("expected cached artifact %s to exist", key1) - } - if _, exists := artifacts2[key2]; !exists { - t.Errorf("expected new artifact %s to exist", key2) - } - - // And the cached artifact should be identical to the first call - if !bytes.Equal(artifacts1[key1], artifacts2[key1]) { - t.Errorf("cached artifact data should be identical") - } - }) -} - -// TestArtifactBuilder_Bundle tests the Bundle method of ArtifactBuilder -func TestArtifactBuilder_Bundle(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - t.Helper() - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("SuccessWithAllDirectories", func(t *testing.T) { - // Given a builder with mock directories and files - builder, mocks := setup(t) - - // Mock directory structure - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - if name == "contexts" || name == "kustomize" || name == "terraform" { - return &mockFileInfo{name: name, isDir: true}, nil - } - return nil, os.ErrNotExist - } - - mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { - switch root { - case "contexts": - fn("contexts/_template", &mockFileInfo{name: "_template", isDir: true}, nil) - fn("contexts/_template/test.jsonnet", &mockFileInfo{name: "test.jsonnet", isDir: false}, nil) - case "kustomize": - fn("kustomize", &mockFileInfo{name: "kustomize", isDir: true}, nil) - fn("kustomize/kustomization.yaml", &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) - case "terraform": - fn("terraform", &mockFileInfo{name: "terraform", isDir: true}, nil) - fn("terraform/main.tf", &mockFileInfo{name: "main.tf", isDir: false}, nil) - default: - // No-op for other roots - } - return nil - } - - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("test content"), nil - } - - mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { - if strings.Contains(targpath, "_template") { - return strings.TrimPrefix(targpath, "contexts/_template/"), nil - } - return filepath.Base(targpath), nil - } - - // When bundling - err := builder.Bundle() - - // Then should succeed - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - // And should have added files - if len(builder.files) == 0 { - t.Error("Expected files to be added") - } - }) - - t.Run("SuccessWithMissingDirectories", func(t *testing.T) { - // Given a builder with some missing directories - builder, mocks := setup(t) - - // Mock only kustomize directory exists - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - if name == "kustomize" { - return &mockFileInfo{name: "kustomize", isDir: true}, nil - } - return nil, os.ErrNotExist - } - - mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { - if root == "kustomize" { - fn("kustomize", &mockFileInfo{name: "kustomize", isDir: true}, nil) - fn("kustomize/kustomization.yaml", &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) - } - return nil - } - - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("test content"), nil - } - - mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { - return filepath.Base(targpath), nil - } - - // When bundling - err := builder.Bundle() - - // Then should succeed - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - }) - - t.Run("ErrorOnWalkFailure", func(t *testing.T) { - // Given a builder with walk error - builder, mocks := setup(t) - - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - if name == "kustomize" { - return &mockFileInfo{name: "kustomize", isDir: true}, nil - } - return nil, os.ErrNotExist - } - - expectedError := errors.New("walk error") - mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { - return expectedError - } - - // When bundling - err := builder.Bundle() - - // Then should return error - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to walk directory") { - t.Errorf("Expected walk error, got %v", err) - } - }) -} - -type mockReference struct{} - -func (m *mockReference) Context() name.Repository { return name.Repository{} } -func (m *mockReference) Identifier() string { return "" } -func (m *mockReference) Name() string { return "" } -func (m *mockReference) String() string { return "" } -func (m *mockReference) Scope(action string) string { return "" } - -func TestArtifactBuilder_GetTemplateData(t *testing.T) { - t.Run("InvalidOCIReference", func(t *testing.T) { - // Given an artifact builder - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - - // When calling GetTemplateData with invalid reference - templateData, err := builder.GetTemplateData("invalid-ref") - - // Then should return error - if err == nil { - t.Fatal("Expected error for invalid OCI reference") - } - if !strings.Contains(err.Error(), "invalid OCI reference") { - t.Errorf("Expected error to contain 'invalid OCI reference', got %v", err) - } - if templateData != nil { - t.Error("Expected nil template data on error") - } - }) - - t.Run("ErrorParsingOCIReference", func(t *testing.T) { - // Given an artifact builder with mock shims - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = &Shims{ - ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { - return nil, fmt.Errorf("parse error") - }, - } - - // When calling GetTemplateData with malformed OCI reference - templateData, err := builder.GetTemplateData("oci://invalid") - - // Then should return error - if err == nil { - t.Fatal("Expected error for malformed OCI reference") - } - if !strings.Contains(err.Error(), "failed to parse OCI reference") { - t.Errorf("Expected error to contain 'failed to parse OCI reference', got %v", err) - } - if templateData != nil { - t.Error("Expected nil template data on error") - } - }) - - t.Run("UsesCachedArtifact", func(t *testing.T) { - // Given an artifact builder with cached data - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - - // Create test tar.gz data - testData := createTestTarGz(t, map[string][]byte{ - "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), - "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), - "_template/schema.yaml": []byte("$schema: https://json-schema.org/draft/2020-12/schema\ntype: object\nproperties: {}\nrequired: []\nadditionalProperties: false"), - "template.jsonnet": []byte("{ template: 'content' }"), - "ignored.yaml": []byte("ignored: content"), - }) - - // Pre-populate cache - builder.ociCache["registry.example.com/test:v1.0.0"] = testData - - downloadCalled := false - builder.shims = &Shims{ - ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { - return &mockReference{}, nil - }, - RemoteImage: func(ref name.Reference, options ...remote.Option) (v1.Image, error) { - downloadCalled = true - return nil, fmt.Errorf("should not be called") - }, - YamlUnmarshal: func(data []byte, v any) error { - if metadata, ok := v.(*BlueprintMetadata); ok { - metadata.Name = "test" - metadata.Version = "v1.0.0" - } - return nil - }, - } - - // When calling GetTemplateData - templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - - // Then should use cached data without downloading - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - if downloadCalled { - t.Error("Expected download not to be called when using cached data") - } - if templateData == nil { - t.Fatal("Expected template data, got nil") - } - if len(templateData) != 5 { - t.Errorf("Expected 5 files (2 .jsonnet + name + ociUrl + schema), got %d", len(templateData)) - } - if string(templateData["template.jsonnet"]) != "{ template: 'content' }" { - t.Errorf("Expected template.jsonnet content to be '{ template: 'content' }', got %s", string(templateData["template.jsonnet"])) - } - if string(templateData["ociUrl"]) != "oci://registry.example.com/test:v1.0.0" { - t.Errorf("Expected ociUrl to be 'oci://registry.example.com/test:v1.0.0', got %s", string(templateData["ociUrl"])) - } - if string(templateData["name"]) != "test" { - t.Errorf("Expected name to be 'test', got %s", string(templateData["name"])) - } - if _, exists := templateData["ignored.yaml"]; exists { - t.Error("Expected ignored.yaml to be filtered out") - } - }) - - t.Run("FiltersOnlyJsonnetFiles", func(t *testing.T) { - // Given an artifact builder with cached data containing multiple file types - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - - // Create test tar.gz data with mixed file types - testData := createTestTarGz(t, map[string][]byte{ - "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), - "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), - "_template/schema.yaml": []byte("$schema: https://json-schema.org/draft/2020-12/schema\ntype: object\nproperties: {}\nrequired: []\nadditionalProperties: false"), - "template.jsonnet": []byte("{ template: 'content' }"), - "config.yaml": []byte("config: value"), - "script.sh": []byte("#!/bin/bash"), - "another.jsonnet": []byte("{ another: 'template' }"), - "README.md": []byte("# README"), - "nested/dir.jsonnet": []byte("{ nested: 'template' }"), - "nested/config.json": []byte("{ json: 'config' }"), - }) - - // Pre-populate cache - builder.ociCache["registry.example.com/test:v1.0.0"] = testData - - builder.shims = &Shims{ - ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { - return &mockReference{}, nil - }, - YamlUnmarshal: func(data []byte, v any) error { - if metadata, ok := v.(*BlueprintMetadata); ok { - metadata.Name = "test" - metadata.Version = "v1.0.0" - } - return nil - }, - } - - // When calling GetTemplateData - templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - - // Then should only return .jsonnet files - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - if templateData == nil { - t.Fatal("Expected template data, got nil") - } - if len(templateData) != 7 { - t.Errorf("Expected 7 files (4 .jsonnet + name + ociUrl + schema), got %d", len(templateData)) - } - - // And should contain OCI metadata - if string(templateData["ociUrl"]) != "oci://registry.example.com/test:v1.0.0" { - t.Errorf("Expected ociUrl to be 'oci://registry.example.com/test:v1.0.0', got %s", string(templateData["ociUrl"])) - } - if string(templateData["name"]) != "test" { - t.Errorf("Expected name to be 'test', got %s", string(templateData["name"])) - } - - // And should contain only .jsonnet files - expectedFiles := []string{"blueprint.jsonnet", "template.jsonnet", "another.jsonnet", "nested/dir.jsonnet"} - for _, expectedFile := range expectedFiles { - if _, exists := templateData[expectedFile]; !exists { - t.Errorf("Expected %s to be included", expectedFile) - } - } - - // And should not contain non-.jsonnet files - excludedFiles := []string{"config.yaml", "script.sh", "README.md", "nested/config.json"} - for _, excludedFile := range excludedFiles { - if _, exists := templateData[excludedFile]; exists { - t.Errorf("Expected %s to be filtered out", excludedFile) - } - } - }) - - t.Run("ErrorWhenMissingMetadata", func(t *testing.T) { - // Given an artifact builder with cached data missing metadata.yaml - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - - // Create test tar.gz data without metadata.yaml - testData := createTestTarGz(t, map[string][]byte{ - "_template/blueprint.jsonnet": []byte("{ template: 'content' }"), - "other.jsonnet": []byte("{ other: 'content' }"), - }) - - // Pre-populate cache - builder.ociCache["registry.example.com/test:v1.0.0"] = testData - - builder.shims = &Shims{ - ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { - return &mockReference{}, nil - }, - } - - // When calling GetTemplateData - templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - - // Then should return error - if err == nil { - t.Fatal("Expected error for missing metadata.yaml") - } - if !strings.Contains(err.Error(), "OCI artifact missing required metadata.yaml file") { - t.Errorf("Expected error to contain 'OCI artifact missing required metadata.yaml file', got %v", err) - } - if templateData != nil { - t.Error("Expected nil template data on error") - } - }) - - t.Run("ErrorWhenMissingBlueprintJsonnet", func(t *testing.T) { - // Given an artifact builder with cached data missing _template/blueprint.jsonnet - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - - // Create test tar.gz data without _template/blueprint.jsonnet - testData := createTestTarGz(t, map[string][]byte{ - "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), - "other.jsonnet": []byte("{ other: 'content' }"), - "config.jsonnet": []byte("{ config: 'content' }"), - }) - - // Pre-populate cache - builder.ociCache["registry.example.com/test:v1.0.0"] = testData - - builder.shims = &Shims{ - ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { - return &mockReference{}, nil - }, - YamlUnmarshal: func(data []byte, v any) error { - if metadata, ok := v.(*BlueprintMetadata); ok { - metadata.Name = "test" - metadata.Version = "v1.0.0" - } - return nil - }, - } - - // When calling GetTemplateData - templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - - // Then should return error - if err == nil { - t.Fatal("Expected error for missing _template/blueprint.jsonnet") - } - if !strings.Contains(err.Error(), "OCI artifact missing required _template/blueprint.jsonnet file") { - t.Errorf("Expected error to contain 'OCI artifact missing required _template/blueprint.jsonnet file', got %v", err) - } - if templateData != nil { - t.Error("Expected nil template data on error") - } - }) - - t.Run("SuccessWithOptionalSchema", func(t *testing.T) { - // Given an artifact builder with cached data missing optional schema.yaml - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - - // Create test tar.gz data without schema.yaml - testData := createTestTarGz(t, map[string][]byte{ - "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), - "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), - "_template/other.jsonnet": []byte("{ other: 'content' }"), - "config.yaml": []byte("config: value"), - }) - - // Pre-populate cache - builder.ociCache["registry.example.com/test:v1.0.0"] = testData - - builder.shims = &Shims{ - ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { - return &mockReference{}, nil - }, - YamlUnmarshal: func(data []byte, v any) error { - if metadata, ok := v.(*BlueprintMetadata); ok { - metadata.Name = "test" - metadata.Version = "v1.0.0" - } - return nil - }, - } - - // When calling GetTemplateData - templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - - // Then should succeed without schema.yaml - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - if templateData == nil { - t.Fatal("Expected template data, got nil") - } - - // And should contain required files but not schema - if _, exists := templateData["blueprint.jsonnet"]; !exists { - t.Error("Expected blueprint.jsonnet to be included") - } - if _, exists := templateData["other.jsonnet"]; !exists { - t.Error("Expected other.jsonnet to be included") - } - if _, exists := templateData["name"]; !exists { - t.Error("Expected name to be included") - } - if _, exists := templateData["ociUrl"]; !exists { - t.Error("Expected ociUrl to be included") - } - if _, exists := templateData["schema"]; exists { - t.Error("Expected schema to not be included when schema.yaml is missing") - } - - // And should have correct count (2 .jsonnet + name + ociUrl, no schema) - if len(templateData) != 4 { - t.Errorf("Expected 4 files (2 .jsonnet + name + ociUrl), got %d", len(templateData)) - } - }) - - t.Run("SuccessWithRequiredFiles", func(t *testing.T) { - // Given an artifact builder with cached data containing required files - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - - // Create test tar.gz data with required files - testData := createTestTarGz(t, map[string][]byte{ - "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), - "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), - "_template/schema.yaml": []byte("$schema: https://json-schema.org/draft/2020-12/schema\ntype: object\nproperties: {}\nrequired: []\nadditionalProperties: false"), - "_template/other.jsonnet": []byte("{ other: 'content' }"), - "config.yaml": []byte("config: value"), - }) - - // Pre-populate cache - builder.ociCache["registry.example.com/test:v1.0.0"] = testData - - builder.shims = &Shims{ - ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { - return &mockReference{}, nil - }, - YamlUnmarshal: func(data []byte, v any) error { - if metadata, ok := v.(*BlueprintMetadata); ok { - metadata.Name = "test" - metadata.Version = "v1.0.0" - } - return nil - }, - } - - // When calling GetTemplateData - templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - - // Then should succeed - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - if templateData == nil { - t.Fatal("Expected template data, got nil") - } - - // And should contain required files - if _, exists := templateData["blueprint.jsonnet"]; !exists { - t.Error("Expected blueprint.jsonnet to be included") - } - if _, exists := templateData["other.jsonnet"]; !exists { - t.Error("Expected other.jsonnet to be included") - } - if string(templateData["name"]) != "test" { - t.Errorf("Expected name to be 'test', got %s", string(templateData["name"])) - } - if string(templateData["ociUrl"]) != "oci://registry.example.com/test:v1.0.0" { - t.Errorf("Expected ociUrl to be 'oci://registry.example.com/test:v1.0.0', got %s", string(templateData["ociUrl"])) - } - if _, exists := templateData["schema"]; !exists { - t.Error("Expected schema key to be included") - } - }) - - t.Run("ValidatesCliVersionFromMetadata", func(t *testing.T) { - // Given an artifact builder with cached data containing cliVersion - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - - // Create test tar.gz data with cliVersion in metadata - testData := createTestTarGz(t, map[string][]byte{ - "metadata.yaml": []byte("name: test\nversion: v1.0.0\ncliVersion: '>=1.0.0'\n"), - "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), - }) - - // Pre-populate cache - builder.ociCache["registry.example.com/test:v1.0.0"] = testData - - builder.shims = &Shims{ - ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { - return &mockReference{}, nil - }, - YamlUnmarshal: func(data []byte, v any) error { - if metadata, ok := v.(*BlueprintMetadata); ok { - metadata.Name = "test" - metadata.Version = "v1.0.0" - metadata.CliVersion = ">=1.0.0" - } - return nil - }, - } - - // When calling GetTemplateData - _, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - - // Then should succeed (validation skipped when cliVersion is empty) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - }) -} - -// createTestTarGz creates a test tar archive with the given files -func createTestTarGz(t *testing.T, files map[string][]byte) []byte { - t.Helper() - - var buf bytes.Buffer - tarWriter := tar.NewWriter(&buf) - - for path, content := range files { - header := &tar.Header{ - Name: path, - Mode: 0644, - Size: int64(len(content)), - } - - if err := tarWriter.WriteHeader(header); err != nil { - t.Fatalf("Failed to write tar header: %v", err) - } - - if _, err := tarWriter.Write(content); err != nil { - t.Fatalf("Failed to write tar content: %v", err) - } - } - - tarWriter.Close() - - return buf.Bytes() -} - -// ============================================================================= -// Test Package Functions -// ============================================================================= - -func TestParseOCIReference(t *testing.T) { - testCases := []struct { - name string - input string - expected *OCIArtifactInfo - expectError bool - }{ - { - name: "EmptyString", - input: "", - expected: nil, - expectError: false, - }, - { - name: "FullOCIURL", - input: "oci://ghcr.io/windsorcli/core:v1.0.0", - expected: &OCIArtifactInfo{ - Name: "core", - URL: "oci://ghcr.io/windsorcli/core:v1.0.0", - Tag: "v1.0.0", - }, - expectError: false, - }, - { - name: "ShortFormat", - input: "windsorcli/core:v1.0.0", - expected: &OCIArtifactInfo{ - Name: "core", - URL: "oci://ghcr.io/windsorcli/core:v1.0.0", - Tag: "v1.0.0", - }, - expectError: false, - }, - { - name: "MissingVersion", - input: "windsorcli/core", - expected: nil, - expectError: true, - }, - { - name: "InvalidFormat", - input: "core:v1.0.0", - expected: nil, - expectError: true, - }, - { - name: "EmptyVersion", - input: "windsorcli/core:", - expected: nil, - expectError: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result, err := ParseOCIReference(tc.input) - - if tc.expectError { - if err == nil { - t.Errorf("Expected error but got none") - } - return - } - - if err != nil { - t.Errorf("Unexpected error: %v", err) - return - } - - if tc.expected == nil { - if result != nil { - t.Errorf("Expected nil result but got: %+v", result) - } - return - } - - if result == nil { - t.Errorf("Expected result but got nil") - return - } - - if result.Name != tc.expected.Name { - t.Errorf("Expected name %s but got %s", tc.expected.Name, result.Name) - } - - if result.URL != tc.expected.URL { - t.Errorf("Expected URL %s but got %s", tc.expected.URL, result.URL) - } - - if result.Tag != tc.expected.Tag { - t.Errorf("Expected tag %s but got %s", tc.expected.Tag, result.Tag) - } - }) - } -} - -// TestArtifactBuilder_findMatchingProcessor tests the findMatchingProcessor method of ArtifactBuilder -func TestArtifactBuilder_findMatchingProcessor(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - t.Helper() - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("FindsMatchingProcessor", func(t *testing.T) { - // Given a builder with processors - builder, _ := setup(t) - - processors := []PathProcessor{ - {Pattern: "contexts/_template"}, - {Pattern: "kustomize"}, - {Pattern: "terraform"}, - } - - // When finding matching processor - processor := builder.findMatchingProcessor("kustomize/file.yaml", processors) - - // Then should find the kustomize processor - if processor == nil { - t.Error("Expected to find matching processor") - } - if processor.Pattern != "kustomize" { - t.Errorf("Expected kustomize pattern, got %s", processor.Pattern) - } - }) - - t.Run("ReturnsNilForNoMatch", func(t *testing.T) { - // Given a builder with processors - builder, _ := setup(t) - - processors := []PathProcessor{ - {Pattern: "contexts/_template"}, - {Pattern: "kustomize"}, - {Pattern: "terraform"}, - } - - // When finding matching processor for non-matching path - processor := builder.findMatchingProcessor("other/file.txt", processors) - - // Then should return nil - if processor != nil { - t.Error("Expected no matching processor") - } - }) - - t.Run("MatchesFirstProcessor", func(t *testing.T) { - // Given a builder with overlapping processors - builder, _ := setup(t) - - processors := []PathProcessor{ - {Pattern: "test"}, - {Pattern: "test/sub"}, - } - - // When finding matching processor - processor := builder.findMatchingProcessor("test/file.txt", processors) - - // Then should find the first matching processor - if processor == nil { - t.Error("Expected to find matching processor") - } - if processor.Pattern != "test" { - t.Errorf("Expected test pattern, got %s", processor.Pattern) - } - }) -} - -// TestArtifactBuilder_shouldSkipTerraformFile tests the shouldSkipTerraformFile method of ArtifactBuilder -func TestArtifactBuilder_shouldSkipTerraformFile(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - t.Helper() - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("SkipsTerraformStateFiles", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) - - // When checking terraform state files - shouldSkip := builder.shouldSkipTerraformFile("terraform.tfstate") - shouldSkipBackup := builder.shouldSkipTerraformFile("terraform.tfstate.backup") - - // Then should skip both - if !shouldSkip { - t.Error("Expected to skip terraform.tfstate") - } - if !shouldSkipBackup { - t.Error("Expected to skip terraform.tfstate.backup") - } - }) - - t.Run("SkipsTerraformOverrideFiles", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) - - // When checking terraform override files - shouldSkip := builder.shouldSkipTerraformFile("override.tf") - shouldSkipJson := builder.shouldSkipTerraformFile("override.tf.json") - shouldSkipUnderscore := builder.shouldSkipTerraformFile("test_override.tf") - - // Then should skip all - if !shouldSkip { - t.Error("Expected to skip override.tf") - } - if !shouldSkipJson { - t.Error("Expected to skip override.tf.json") - } - if !shouldSkipUnderscore { - t.Error("Expected to skip test_override.tf") - } - }) - - t.Run("SkipsTerraformVarsFiles", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) - - // When checking terraform vars files - shouldSkip := builder.shouldSkipTerraformFile("terraform.tfvars") - shouldSkipJson := builder.shouldSkipTerraformFile("terraform.tfvars.json") - - // Then should skip both - if !shouldSkip { - t.Error("Expected to skip terraform.tfvars") - } - if !shouldSkipJson { - t.Error("Expected to skip terraform.tfvars.json") - } - }) - - t.Run("SkipsTerraformPlanFiles", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) - - // When checking terraform plan files - shouldSkip := builder.shouldSkipTerraformFile("terraform.tfplan") - - // Then should skip - if !shouldSkip { - t.Error("Expected to skip terraform.tfplan") - } - }) - - t.Run("SkipsTerraformConfigFiles", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) - - // When checking terraform config files - shouldSkipRc := builder.shouldSkipTerraformFile(".terraformrc") - shouldSkipTerraformRc := builder.shouldSkipTerraformFile("terraform.rc") - - // Then should skip both - if !shouldSkipRc { - t.Error("Expected to skip .terraformrc") - } - if !shouldSkipTerraformRc { - t.Error("Expected to skip terraform.rc") - } - }) - - t.Run("SkipsCrashLogFiles", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) - - // When checking crash log files - shouldSkip := builder.shouldSkipTerraformFile("crash.log") - shouldSkipPrefixed := builder.shouldSkipTerraformFile("crash.123.log") - - // Then should skip both - if !shouldSkip { - t.Error("Expected to skip crash.log") - } - if !shouldSkipPrefixed { - t.Error("Expected to skip crash.123.log") - } - }) - - t.Run("DoesNotSkipRegularFiles", func(t *testing.T) { - // Given a builder - builder, _ := setup(t) - - // When checking regular terraform files - shouldSkip := builder.shouldSkipTerraformFile("main.tf") - shouldSkipVar := builder.shouldSkipTerraformFile("variables.tf") - shouldSkipOutput := builder.shouldSkipTerraformFile("outputs.tf") - - // Then should not skip any - if shouldSkip { - t.Error("Expected not to skip main.tf") - } - if shouldSkipVar { - t.Error("Expected not to skip variables.tf") - } - if shouldSkipOutput { - t.Error("Expected not to skip outputs.tf") - } - }) -} - -// TestArtifactBuilder_walkAndProcessFiles tests the walkAndProcessFiles method of ArtifactBuilder -func TestArtifactBuilder_walkAndProcessFiles(t *testing.T) { - setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { - t.Helper() - mocks := setupArtifactMocks(t) - builder := NewArtifactBuilder(mocks.Runtime) - builder.shims = mocks.Shims - return builder, mocks - } - - t.Run("SuccessWithMatchingFiles", func(t *testing.T) { - // Given a builder with processors - builder, mocks := setup(t) - - processors := []PathProcessor{ - { - Pattern: "test", - Handler: func(relPath string, data []byte, mode os.FileMode) error { - return builder.addFile("test/"+relPath, data, mode) - }, - }, - } - - // Mock directory exists - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - if name == "test" { - return &mockFileInfo{name: "test", isDir: true}, nil - } - return nil, os.ErrNotExist - } - - // Mock walk function - mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { - if root == "test" { - fn("test", &mockFileInfo{name: "test", isDir: true}, nil) - fn("test/file.txt", &mockFileInfo{name: "file.txt", isDir: false}, nil) - } - return nil - } - - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("test content"), nil - } - - mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { - return "file.txt", nil - } - - // When walking and processing files - err := builder.walkAndProcessFiles(processors) - - // Then should succeed - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - // And should have added files - if len(builder.files) == 0 { - t.Error("Expected files to be added") - } - }) - - t.Run("SuccessWithNoMatchingFiles", func(t *testing.T) { - // Given a builder with processors that don't match - builder, mocks := setup(t) - - processors := []PathProcessor{ - { - Pattern: "other", - Handler: func(relPath string, data []byte, mode os.FileMode) error { - return builder.addFile("other/"+relPath, data, mode) - }, - }, - } - - // Mock directory exists - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - if name == "test" { - return &mockFileInfo{name: "test", isDir: true}, nil - } - return nil, os.ErrNotExist - } - - // Mock walk function - mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { - if root == "test" { - fn("test", &mockFileInfo{name: "test", isDir: true}, nil) - fn("test/file.txt", &mockFileInfo{name: "file.txt", isDir: false}, nil) - } - return nil - } - - // When walking and processing files - err := builder.walkAndProcessFiles(processors) - - // Then should succeed - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - // And should not have added files - if len(builder.files) != 0 { - t.Error("Expected no files to be added") - } - }) - - t.Run("SuccessWithSkipTerraformDirectory", func(t *testing.T) { - // Given a builder with terraform directory - builder, mocks := setup(t) - - processors := []PathProcessor{ - { - Pattern: "terraform", - Handler: func(relPath string, data []byte, mode os.FileMode) error { - return builder.addFile("terraform/"+relPath, data, mode) - }, - }, - } - - // Mock directory exists - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - if name == "terraform" { - return &mockFileInfo{name: "terraform", isDir: true}, nil - } - return nil, os.ErrNotExist - } - - // Mock walk function with .terraform directory mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { - if root == "terraform" { + switch root { + case "contexts": + fn("contexts/_template", &mockFileInfo{name: "_template", isDir: true}, nil) + fn("contexts/_template/test.jsonnet", &mockFileInfo{name: "test.jsonnet", isDir: false}, nil) + case "kustomize": + fn("kustomize", &mockFileInfo{name: "kustomize", isDir: true}, nil) + fn("kustomize/kustomization.yaml", &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) + case "terraform": fn("terraform", &mockFileInfo{name: "terraform", isDir: true}, nil) - fn("terraform/.terraform", &mockFileInfo{name: ".terraform", isDir: true}, nil) fn("terraform/main.tf", &mockFileInfo{name: "main.tf", isDir: false}, nil) + default: + // No-op for other roots } return nil } @@ -3870,43 +2036,56 @@ func TestArtifactBuilder_walkAndProcessFiles(t *testing.T) { } mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { - return "main.tf", nil + if strings.Contains(targpath, "_template") { + return strings.TrimPrefix(targpath, "contexts/_template/"), nil + } + return filepath.Base(targpath), nil } - // When walking and processing files - err := builder.walkAndProcessFiles(processors) + // When bundling + err := builder.Bundle() // Then should succeed if err != nil { t.Errorf("Expected no error, got %v", err) } - // And should have added files (but not .terraform contents) + // And should have added files if len(builder.files) == 0 { t.Error("Expected files to be added") } }) t.Run("SuccessWithMissingDirectories", func(t *testing.T) { - // Given a builder with missing directories + // Given a builder with some missing directories builder, mocks := setup(t) - processors := []PathProcessor{ - { - Pattern: "missing", - Handler: func(relPath string, data []byte, mode os.FileMode) error { - return builder.addFile("missing/"+relPath, data, mode) - }, - }, - } - - // Mock directory doesn't exist + // Mock only kustomize directory exists mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "kustomize" { + return &mockFileInfo{name: "kustomize", isDir: true}, nil + } return nil, os.ErrNotExist } - // When walking and processing files - err := builder.walkAndProcessFiles(processors) + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + if root == "kustomize" { + fn("kustomize", &mockFileInfo{name: "kustomize", isDir: true}, nil) + fn("kustomize/kustomization.yaml", &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) + } + return nil + } + + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("test content"), nil + } + + mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { + return filepath.Base(targpath), nil + } + + // When bundling + err := builder.Bundle() // Then should succeed if err != nil { @@ -3918,30 +2097,20 @@ func TestArtifactBuilder_walkAndProcessFiles(t *testing.T) { // Given a builder with walk error builder, mocks := setup(t) - processors := []PathProcessor{ - { - Pattern: "test", - Handler: func(relPath string, data []byte, mode os.FileMode) error { - return builder.addFile("test/"+relPath, data, mode) - }, - }, - } - - // Mock directory exists mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - if name == "test" { - return &mockFileInfo{name: "test", isDir: true}, nil + if name == "kustomize" { + return &mockFileInfo{name: "kustomize", isDir: true}, nil } return nil, os.ErrNotExist } - expectedError := fmt.Errorf("walk error") + expectedError := errors.New("walk error") mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { return expectedError } - // When walking and processing files - err := builder.walkAndProcessFiles(processors) + // When bundling + err := builder.Bundle() // Then should return error if err == nil { @@ -3953,550 +2122,527 @@ func TestArtifactBuilder_walkAndProcessFiles(t *testing.T) { }) } -// ============================================================================= -// Test Helper Functions -// ============================================================================= - -func TestParseRegistryURL(t *testing.T) { - t.Run("ParsesRegistryURLWithTag", func(t *testing.T) { - // Given a registry URL with tag - url := "ghcr.io/windsorcli/core:v1.0.0" - - // When parsing the URL - registryBase, repoName, tag, err := ParseRegistryURL(url) +func TestArtifactBuilder_GetTemplateData(t *testing.T) { + t.Run("InvalidOCIReference", func(t *testing.T) { + // Given an artifact builder + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) - // Then parsing should succeed - if err != nil { - t.Errorf("Expected no error, got %v", err) - } + // When calling GetTemplateData with invalid reference + templateData, err := builder.GetTemplateData("invalid-ref") - // And components should be correct - if registryBase != "ghcr.io" { - t.Errorf("Expected registryBase 'ghcr.io', got '%s'", registryBase) + // Then should return error + if err == nil { + t.Fatal("Expected error for invalid OCI reference") } - if repoName != "windsorcli/core" { - t.Errorf("Expected repoName 'windsorcli/core', got '%s'", repoName) + if !strings.Contains(err.Error(), "invalid OCI reference") { + t.Errorf("Expected error to contain 'invalid OCI reference', got %v", err) } - if tag != "v1.0.0" { - t.Errorf("Expected tag 'v1.0.0', got '%s'", tag) + if templateData != nil { + t.Error("Expected nil template data on error") } }) - t.Run("ParsesRegistryURLWithoutTag", func(t *testing.T) { - // Given a registry URL without tag - url := "docker.io/myuser/myblueprint" - - // When parsing the URL - registryBase, repoName, tag, err := ParseRegistryURL(url) - - // Then parsing should succeed - if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Run("ErrorParsingOCIReference", func(t *testing.T) { + // Given an artifact builder with mock shims + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, fmt.Errorf("parse error") + }, } - // And components should be correct - if registryBase != "docker.io" { - t.Errorf("Expected registryBase 'docker.io', got '%s'", registryBase) + // When calling GetTemplateData with malformed OCI reference + templateData, err := builder.GetTemplateData("oci://invalid") + + // Then should return error + if err == nil { + t.Fatal("Expected error for malformed OCI reference") } - if repoName != "myuser/myblueprint" { - t.Errorf("Expected repoName 'myuser/myblueprint', got '%s'", repoName) + if !strings.Contains(err.Error(), "failed to parse OCI reference") { + t.Errorf("Expected error to contain 'failed to parse OCI reference', got %v", err) } - if tag != "" { - t.Errorf("Expected empty tag, got '%s'", tag) + if templateData != nil { + t.Error("Expected nil template data on error") } }) - t.Run("ParsesRegistryURLWithOCIPrefix", func(t *testing.T) { - // Given a registry URL with oci:// prefix - url := "oci://registry.example.com/namespace/repo:latest" + t.Run("UsesCachedArtifact", func(t *testing.T) { + // Given an artifact builder with cached data + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) - // When parsing the URL - registryBase, repoName, tag, err := ParseRegistryURL(url) + // Create test tar.gz data + testData := createTestTarGz(t, map[string][]byte{ + "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), + "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), + "_template/schema.yaml": []byte("$schema: https://json-schema.org/draft/2020-12/schema\ntype: object\nproperties: {}\nrequired: []\nadditionalProperties: false"), + "template.jsonnet": []byte("{ template: 'content' }"), + "ignored.yaml": []byte("ignored: content"), + }) - // Then parsing should succeed - if err != nil { - t.Errorf("Expected no error, got %v", err) - } + // Pre-populate cache + builder.ociCache["registry.example.com/test:v1.0.0"] = testData - // And prefix should be stripped - if registryBase != "registry.example.com" { - t.Errorf("Expected registryBase 'registry.example.com', got '%s'", registryBase) - } - if repoName != "namespace/repo" { - t.Errorf("Expected repoName 'namespace/repo', got '%s'", repoName) - } - if tag != "latest" { - t.Errorf("Expected tag 'latest', got '%s'", tag) + downloadCalled := false + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, + RemoteImage: func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + downloadCalled = true + return nil, fmt.Errorf("should not be called") + }, + YamlUnmarshal: func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadata); ok { + metadata.Name = "test" + metadata.Version = "v1.0.0" + } + return nil + }, } - }) - - t.Run("ParsesRegistryURLWithMultipleSlashes", func(t *testing.T) { - // Given a registry URL with nested repository path - url := "registry.com/org/project/subproject:v2.0" - // When parsing the URL - registryBase, repoName, tag, err := ParseRegistryURL(url) + // When calling GetTemplateData + templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - // Then parsing should succeed + // Then should use cached data without downloading if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("Expected no error, got %v", err) } - - // And full repository path should be preserved - if registryBase != "registry.com" { - t.Errorf("Expected registryBase 'registry.com', got '%s'", registryBase) + if downloadCalled { + t.Error("Expected download not to be called when using cached data") + } + if templateData == nil { + t.Fatal("Expected template data, got nil") + } + if len(templateData) != 5 { + t.Errorf("Expected 5 files (2 .jsonnet + name + ociUrl + schema), got %d", len(templateData)) + } + if string(templateData["template.jsonnet"]) != "{ template: 'content' }" { + t.Errorf("Expected template.jsonnet content to be '{ template: 'content' }', got %s", string(templateData["template.jsonnet"])) + } + if string(templateData["ociUrl"]) != "oci://registry.example.com/test:v1.0.0" { + t.Errorf("Expected ociUrl to be 'oci://registry.example.com/test:v1.0.0', got %s", string(templateData["ociUrl"])) } - if repoName != "org/project/subproject" { - t.Errorf("Expected repoName 'org/project/subproject', got '%s'", repoName) + if string(templateData["name"]) != "test" { + t.Errorf("Expected name to be 'test', got %s", string(templateData["name"])) } - if tag != "v2.0" { - t.Errorf("Expected tag 'v2.0', got '%s'", tag) + if _, exists := templateData["ignored.yaml"]; exists { + t.Error("Expected ignored.yaml to be filtered out") } }) - t.Run("ReturnsErrorForInvalidFormatWithoutSlash", func(t *testing.T) { - // Given an invalid registry URL without slash - url := "registry.example.com" + t.Run("FiltersOnlyJsonnetFiles", func(t *testing.T) { + // Given an artifact builder with cached data containing multiple file types + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) - // When parsing the URL - registryBase, repoName, tag, err := ParseRegistryURL(url) + // Create test tar.gz data with mixed file types + testData := createTestTarGz(t, map[string][]byte{ + "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), + "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), + "_template/schema.yaml": []byte("$schema: https://json-schema.org/draft/2020-12/schema\ntype: object\nproperties: {}\nrequired: []\nadditionalProperties: false"), + "template.jsonnet": []byte("{ template: 'content' }"), + "config.yaml": []byte("config: value"), + "script.sh": []byte("#!/bin/bash"), + "another.jsonnet": []byte("{ another: 'template' }"), + "README.md": []byte("# README"), + "nested/dir.jsonnet": []byte("{ nested: 'template' }"), + "nested/config.json": []byte("{ json: 'config' }"), + }) - // Then error should be returned - if err == nil { - t.Error("Expected error for invalid format, got nil") - } + // Pre-populate cache + builder.ociCache["registry.example.com/test:v1.0.0"] = testData - // And error should indicate invalid format - if !strings.Contains(err.Error(), "invalid registry format") { - t.Errorf("Expected error about invalid format, got: %v", err) + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, + YamlUnmarshal: func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadata); ok { + metadata.Name = "test" + metadata.Version = "v1.0.0" + } + return nil + }, } - // And components should be empty - if registryBase != "" || repoName != "" || tag != "" { - t.Errorf("Expected empty components on error, got: base=%s, repo=%s, tag=%s", registryBase, repoName, tag) - } - }) + // When calling GetTemplateData + templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - t.Run("ReturnsErrorForEmptyString", func(t *testing.T) { - // Given an empty URL - url := "" + // Then should only return .jsonnet files + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if templateData == nil { + t.Fatal("Expected template data, got nil") + } + if len(templateData) != 7 { + t.Errorf("Expected 7 files (4 .jsonnet + name + ociUrl + schema), got %d", len(templateData)) + } - // When parsing the URL - registryBase, repoName, tag, err := ParseRegistryURL(url) + // And should contain OCI metadata + if string(templateData["ociUrl"]) != "oci://registry.example.com/test:v1.0.0" { + t.Errorf("Expected ociUrl to be 'oci://registry.example.com/test:v1.0.0', got %s", string(templateData["ociUrl"])) + } + if string(templateData["name"]) != "test" { + t.Errorf("Expected name to be 'test', got %s", string(templateData["name"])) + } - // Then error should be returned - if err == nil { - t.Error("Expected error for empty string, got nil") + // And should contain only .jsonnet files + expectedFiles := []string{"blueprint.jsonnet", "template.jsonnet", "another.jsonnet", "nested/dir.jsonnet"} + for _, expectedFile := range expectedFiles { + if _, exists := templateData[expectedFile]; !exists { + t.Errorf("Expected %s to be included", expectedFile) + } } - // And components should be empty - if registryBase != "" || repoName != "" || tag != "" { - t.Errorf("Expected empty components on error, got: base=%s, repo=%s, tag=%s", registryBase, repoName, tag) + // And should not contain non-.jsonnet files + excludedFiles := []string{"config.yaml", "script.sh", "README.md", "nested/config.json"} + for _, excludedFile := range excludedFiles { + if _, exists := templateData[excludedFile]; exists { + t.Errorf("Expected %s to be filtered out", excludedFile) + } } }) - t.Run("HandlesRegistryURLWithColonInTag", func(t *testing.T) { - // Given a registry URL with multiple colons (edge case) - // The parser uses the last colon to separate repo from tag - url := "registry.com/repo:tag:with:colons" + t.Run("ErrorWhenMissingMetadata", func(t *testing.T) { + // Given an artifact builder with cached data missing metadata.yaml + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + + // Create test tar.gz data without metadata.yaml + testData := createTestTarGz(t, map[string][]byte{ + "_template/blueprint.jsonnet": []byte("{ template: 'content' }"), + "other.jsonnet": []byte("{ other: 'content' }"), + }) - // When parsing the URL - registryBase, repoName, tag, err := ParseRegistryURL(url) + // Pre-populate cache + builder.ociCache["registry.example.com/test:v1.0.0"] = testData - // Then parsing should succeed using last colon - if err != nil { - t.Errorf("Expected no error, got %v", err) + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, } - // And components should be correct (last colon is used for tag) - if registryBase != "registry.com" { - t.Errorf("Expected registryBase 'registry.com', got '%s'", registryBase) + // When calling GetTemplateData + templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") + + // Then should return error + if err == nil { + t.Fatal("Expected error for missing metadata.yaml") } - if repoName != "repo:tag:with" { - t.Errorf("Expected repoName 'repo:tag:with', got '%s'", repoName) + if !strings.Contains(err.Error(), "OCI artifact missing required metadata.yaml file") { + t.Errorf("Expected error to contain 'OCI artifact missing required metadata.yaml file', got %v", err) } - if tag != "colons" { - t.Errorf("Expected tag 'colons', got '%s'", tag) + if templateData != nil { + t.Error("Expected nil template data on error") } }) -} -func TestIsAuthenticationError(t *testing.T) { - t.Run("ReturnsTrueForUNAUTHORIZED", func(t *testing.T) { - // Given an error with UNAUTHORIZED - err := fmt.Errorf("UNAUTHORIZED: access denied") + t.Run("ErrorWhenMissingBlueprintJsonnet", func(t *testing.T) { + // Given an artifact builder with cached data missing _template/blueprint.jsonnet + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + + // Create test tar.gz data without _template/blueprint.jsonnet + testData := createTestTarGz(t, map[string][]byte{ + "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), + "other.jsonnet": []byte("{ other: 'content' }"), + "config.jsonnet": []byte("{ config: 'content' }"), + }) - // When checking if it's an authentication error - result := IsAuthenticationError(err) + // Pre-populate cache + builder.ociCache["registry.example.com/test:v1.0.0"] = testData - // Then it should return true - if !result { - t.Error("Expected true for UNAUTHORIZED error") + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, + YamlUnmarshal: func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadata); ok { + metadata.Name = "test" + metadata.Version = "v1.0.0" + } + return nil + }, } - }) - t.Run("ReturnsTrueForUnauthorized", func(t *testing.T) { - // Given an error with unauthorized - err := fmt.Errorf("unauthorized access") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) + // When calling GetTemplateData + templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - // Then it should return true - if !result { - t.Error("Expected true for unauthorized error") + // Then should return error + if err == nil { + t.Fatal("Expected error for missing _template/blueprint.jsonnet") } - }) - - t.Run("ReturnsTrueForAuthenticationRequired", func(t *testing.T) { - // Given an error with authentication required - err := fmt.Errorf("authentication required to access this resource") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - - // Then it should return true - if !result { - t.Error("Expected true for authentication required error") + if !strings.Contains(err.Error(), "OCI artifact missing required _template/blueprint.jsonnet file") { + t.Errorf("Expected error to contain 'OCI artifact missing required _template/blueprint.jsonnet file', got %v", err) } - }) - - t.Run("ReturnsTrueForAuthenticationFailed", func(t *testing.T) { - // Given an error with authentication failed - err := fmt.Errorf("authentication failed") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - - // Then it should return true - if !result { - t.Error("Expected true for authentication failed error") + if templateData != nil { + t.Error("Expected nil template data on error") } }) - t.Run("ReturnsTrueForHTTP401", func(t *testing.T) { - // Given an error with HTTP 401 - err := fmt.Errorf("HTTP 401: unauthorized") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - - // Then it should return true - if !result { - t.Error("Expected true for HTTP 401 error") - } - }) + t.Run("SuccessWithOptionalSchema", func(t *testing.T) { + // Given an artifact builder with cached data missing optional schema.yaml + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) - t.Run("ReturnsTrueForHTTP403", func(t *testing.T) { - // Given an error with HTTP 403 - err := fmt.Errorf("HTTP 403: forbidden") + // Create test tar.gz data without schema.yaml + testData := createTestTarGz(t, map[string][]byte{ + "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), + "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), + "_template/other.jsonnet": []byte("{ other: 'content' }"), + "config.yaml": []byte("config: value"), + }) - // When checking if it's an authentication error - result := IsAuthenticationError(err) + // Pre-populate cache + builder.ociCache["registry.example.com/test:v1.0.0"] = testData - // Then it should return true - if !result { - t.Error("Expected true for HTTP 403 error") + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, + YamlUnmarshal: func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadata); ok { + metadata.Name = "test" + metadata.Version = "v1.0.0" + } + return nil + }, } - }) - - t.Run("ReturnsTrueForBlobsUploads", func(t *testing.T) { - // Given an error with blobs/uploads - err := fmt.Errorf("POST https://registry.com/v2/repo/blobs/uploads: unauthorized") - // When checking if it's an authentication error - result := IsAuthenticationError(err) + // When calling GetTemplateData + templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - // Then it should return true - if !result { - t.Error("Expected true for blobs/uploads error") + // Then should succeed without schema.yaml + if err != nil { + t.Fatalf("Expected no error, got %v", err) } - }) - - t.Run("ReturnsTrueForPOSTHTTPS", func(t *testing.T) { - // Given an error with POST https:// - err := fmt.Errorf("POST https://registry.com/v2/repo/manifests/latest: unauthorized") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - - // Then it should return true - if !result { - t.Error("Expected true for POST https:// error") + if templateData == nil { + t.Fatal("Expected template data, got nil") } - }) - - t.Run("ReturnsTrueForFailedToPushArtifact", func(t *testing.T) { - // Given an error with failed to push artifact - err := fmt.Errorf("failed to push artifact: unauthorized") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - // Then it should return true - if !result { - t.Error("Expected true for failed to push artifact error") + // And should contain required files but not schema + if _, exists := templateData["blueprint.jsonnet"]; !exists { + t.Error("Expected blueprint.jsonnet to be included") } - }) - - t.Run("ReturnsTrueForUserCannotBeAuthenticated", func(t *testing.T) { - // Given an error with User cannot be authenticated - err := fmt.Errorf("User cannot be authenticated") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - - // Then it should return true - if !result { - t.Error("Expected true for User cannot be authenticated error") + if _, exists := templateData["other.jsonnet"]; !exists { + t.Error("Expected other.jsonnet to be included") } - }) - - t.Run("ReturnsFalseForNilError", func(t *testing.T) { - // Given a nil error - var err error - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - - // Then it should return false - if result { - t.Error("Expected false for nil error") + if _, exists := templateData["name"]; !exists { + t.Error("Expected name to be included") } - }) - - t.Run("ReturnsFalseForGenericError", func(t *testing.T) { - // Given a generic error - err := fmt.Errorf("network timeout") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - - // Then it should return false - if result { - t.Error("Expected false for generic error") + if _, exists := templateData["ociUrl"]; !exists { + t.Error("Expected ociUrl to be included") } - }) - - t.Run("ReturnsFalseForParseError", func(t *testing.T) { - // Given a parse error - err := fmt.Errorf("failed to parse JSON") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - - // Then it should return false - if result { - t.Error("Expected false for parse error") + if _, exists := templateData["schema"]; exists { + t.Error("Expected schema to not be included when schema.yaml is missing") } - }) - t.Run("ReturnsFalseForNotFoundError", func(t *testing.T) { - // Given a not found error - err := fmt.Errorf("resource not found") - - // When checking if it's an authentication error - result := IsAuthenticationError(err) - - // Then it should return false - if result { - t.Error("Expected false for not found error") + // And should have correct count (2 .jsonnet + name + ociUrl, no schema) + if len(templateData) != 4 { + t.Errorf("Expected 4 files (2 .jsonnet + name + ociUrl), got %d", len(templateData)) } }) -} -func TestValidateCliVersion(t *testing.T) { - t.Run("ReturnsNilWhenConstraintIsEmpty", func(t *testing.T) { - // Given an empty constraint - // When validating - err := ValidateCliVersion("1.0.0", "") + t.Run("SuccessWithRequiredFiles", func(t *testing.T) { + // Given an artifact builder with cached data containing required files + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) - // Then should return nil - if err != nil { - t.Errorf("Expected nil for empty constraint, got: %v", err) - } - }) + // Create test tar.gz data with required files + testData := createTestTarGz(t, map[string][]byte{ + "metadata.yaml": []byte("name: test\nversion: v1.0.0\n"), + "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), + "_template/schema.yaml": []byte("$schema: https://json-schema.org/draft/2020-12/schema\ntype: object\nproperties: {}\nrequired: []\nadditionalProperties: false"), + "_template/other.jsonnet": []byte("{ other: 'content' }"), + "config.yaml": []byte("config: value"), + }) - t.Run("ReturnsNilWhenCliVersionIsEmpty", func(t *testing.T) { - // Given an empty CLI version - // When validating - err := ValidateCliVersion("", ">=1.0.0") + // Pre-populate cache + builder.ociCache["registry.example.com/test:v1.0.0"] = testData - // Then should return nil - if err != nil { - t.Errorf("Expected nil for empty CLI version, got: %v", err) + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, + YamlUnmarshal: func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadata); ok { + metadata.Name = "test" + metadata.Version = "v1.0.0" + } + return nil + }, } - }) - t.Run("ReturnsNilForDevVersion", func(t *testing.T) { - // Given dev version - // When validating - err := ValidateCliVersion("dev", ">=1.0.0") + // When calling GetTemplateData + templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - // Then should return nil + // Then should succeed if err != nil { - t.Errorf("Expected nil for dev version, got: %v", err) + t.Fatalf("Expected no error, got %v", err) } - }) - - t.Run("ReturnsNilForMainVersion", func(t *testing.T) { - // Given main version - // When validating - err := ValidateCliVersion("main", ">=1.0.0") - - // Then should return nil - if err != nil { - t.Errorf("Expected nil for main version, got: %v", err) + if templateData == nil { + t.Fatal("Expected template data, got nil") } - }) - - t.Run("ReturnsNilForLatestVersion", func(t *testing.T) { - // Given latest version - // When validating - err := ValidateCliVersion("latest", ">=1.0.0") - // Then should return nil - if err != nil { - t.Errorf("Expected nil for latest version, got: %v", err) + // And should contain required files + if _, exists := templateData["blueprint.jsonnet"]; !exists { + t.Error("Expected blueprint.jsonnet to be included") } - }) - - t.Run("ReturnsErrorForInvalidCliVersionFormat", func(t *testing.T) { - // Given an invalid CLI version format - // When validating - err := ValidateCliVersion("invalid-version", ">=1.0.0") - - // Then should return error - if err == nil { - t.Error("Expected error for invalid CLI version format") + if _, exists := templateData["other.jsonnet"]; !exists { + t.Error("Expected other.jsonnet to be included") } - if !strings.Contains(err.Error(), "invalid CLI version format") { - t.Errorf("Expected error to contain 'invalid CLI version format', got: %v", err) + if string(templateData["name"]) != "test" { + t.Errorf("Expected name to be 'test', got %s", string(templateData["name"])) } - }) - - t.Run("ReturnsErrorForInvalidConstraint", func(t *testing.T) { - // Given an invalid constraint - // When validating - err := ValidateCliVersion("1.0.0", "invalid-constraint") - - // Then should return error - if err == nil { - t.Error("Expected error for invalid constraint") + if string(templateData["ociUrl"]) != "oci://registry.example.com/test:v1.0.0" { + t.Errorf("Expected ociUrl to be 'oci://registry.example.com/test:v1.0.0', got %s", string(templateData["ociUrl"])) } - if !strings.Contains(err.Error(), "invalid cliVersion constraint") { - t.Errorf("Expected error to contain 'invalid cliVersion constraint', got: %v", err) + if _, exists := templateData["schema"]; !exists { + t.Error("Expected schema key to be included") } }) - t.Run("ReturnsErrorWhenVersionDoesNotSatisfyConstraint", func(t *testing.T) { - // Given a version that doesn't satisfy constraint - // When validating - err := ValidateCliVersion("1.0.0", ">=2.0.0") + t.Run("ValidatesCliVersionFromMetadata", func(t *testing.T) { + // Given an artifact builder with cached data containing cliVersion + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) - // Then should return error - if err == nil { - t.Error("Expected error when version doesn't satisfy constraint") - } - if !strings.Contains(err.Error(), "does not satisfy required constraint") { - t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) - } - }) + // Create test tar.gz data with cliVersion in metadata + testData := createTestTarGz(t, map[string][]byte{ + "metadata.yaml": []byte("name: test\nversion: v1.0.0\ncliVersion: '>=1.0.0'\n"), + "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), + }) - t.Run("ReturnsNilWhenVersionSatisfiesGreaterThanConstraint", func(t *testing.T) { - // Given a version that satisfies >= constraint - // When validating - err := ValidateCliVersion("2.0.0", ">=1.0.0") + // Pre-populate cache + builder.ociCache["registry.example.com/test:v1.0.0"] = testData - // Then should return nil - if err != nil { - t.Errorf("Expected nil for satisfied constraint, got: %v", err) + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, + YamlUnmarshal: func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadata); ok { + metadata.Name = "test" + metadata.Version = "v1.0.0" + metadata.CliVersion = ">=1.0.0" + } + return nil + }, } - }) - t.Run("ReturnsNilWhenVersionSatisfiesLessThanConstraint", func(t *testing.T) { - // Given a version that satisfies < constraint - // When validating - err := ValidateCliVersion("1.0.0", "<2.0.0") + // When calling GetTemplateData + _, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") - // Then should return nil + // Then should succeed (validation skipped when cliVersion is empty) if err != nil { - t.Errorf("Expected nil for satisfied constraint, got: %v", err) + t.Fatalf("Expected no error, got %v", err) } }) - t.Run("ReturnsNilWhenVersionSatisfiesRangeConstraint", func(t *testing.T) { - // Given a version that satisfies range constraint - // When validating - err := ValidateCliVersion("1.5.0", ">=1.0.0 <2.0.0") + t.Run("ErrorWhenPullFails", func(t *testing.T) { + // Given an artifact builder without cached data + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) - // Then should return nil - if err != nil { - t.Errorf("Expected nil for satisfied range constraint, got: %v", err) + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, + RemoteImage: func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return nil, fmt.Errorf("pull error") + }, } - }) - t.Run("ReturnsErrorWhenVersionOutsideRange", func(t *testing.T) { - // Given a version outside range - // When validating - err := ValidateCliVersion("2.5.0", ">=1.0.0 <2.0.0") + // When calling GetTemplateData + templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") // Then should return error if err == nil { - t.Error("Expected error when version outside range") + t.Fatal("Expected error when Pull fails") } - if !strings.Contains(err.Error(), "does not satisfy required constraint") { - t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + if !strings.Contains(err.Error(), "failed to pull OCI artifact") { + t.Errorf("Expected error to contain 'failed to pull OCI artifact', got %v", err) + } + if templateData != nil { + t.Error("Expected nil template data on error") } }) - t.Run("ReturnsNilWhenVersionSatisfiesTildeConstraint", func(t *testing.T) { - // Given a version that satisfies ~ constraint - // When validating - err := ValidateCliVersion("1.2.3", "~1.2.0") + t.Run("ErrorWhenTarReaderNextFails", func(t *testing.T) { + // Given an artifact builder with corrupted tar data + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + + // Create corrupted tar data + corruptedData := []byte("not a valid tar archive") - // Then should return nil - if err != nil { - t.Errorf("Expected nil for satisfied tilde constraint, got: %v", err) + // Pre-populate cache with corrupted data + builder.ociCache["registry.example.com/test:v1.0.0"] = corruptedData + + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, } - }) - t.Run("ReturnsErrorWhenVersionDoesNotSatisfyTildeConstraint", func(t *testing.T) { - // Given a version that doesn't satisfy ~ constraint - // When validating - err := ValidateCliVersion("1.3.0", "~1.2.0") + // When calling GetTemplateData + templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") // Then should return error if err == nil { - t.Error("Expected error when version doesn't satisfy tilde constraint") + t.Fatal("Expected error when tar reader fails") + } + if !strings.Contains(err.Error(), "failed to read tar header") { + t.Errorf("Expected error to contain 'failed to read tar header', got %v", err) } - if !strings.Contains(err.Error(), "does not satisfy required constraint") { - t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + if templateData != nil { + t.Error("Expected nil template data on error") } }) - t.Run("ReturnsNilWhenVersionWithVPrefixSatisfiesConstraint", func(t *testing.T) { - // Given a version with v prefix that satisfies constraint - // When validating - err := ValidateCliVersion("v1.0.0", ">=1.0.0") + t.Run("ErrorWhenMetadataYamlUnmarshalFails", func(t *testing.T) { + // Given an artifact builder with invalid metadata.yaml + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + + // Create test tar.gz data with invalid metadata + testData := createTestTarGz(t, map[string][]byte{ + "metadata.yaml": []byte("invalid: yaml: content: [unclosed"), + "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), + }) + + // Pre-populate cache + builder.ociCache["registry.example.com/test:v1.0.0"] = testData - // Then should return nil - if err != nil { - t.Errorf("Expected nil for v-prefixed version satisfying constraint, got: %v", err) + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, + YamlUnmarshal: func(data []byte, v any) error { + return fmt.Errorf("yaml unmarshal error") + }, } - }) - t.Run("ReturnsErrorWhenVersionWithVPrefixDoesNotSatisfyConstraint", func(t *testing.T) { - // Given a version with v prefix that doesn't satisfy constraint - // When validating - err := ValidateCliVersion("v0.5.0", ">=1.0.0") + // When calling GetTemplateData + templateData, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") // Then should return error if err == nil { - t.Error("Expected error when v-prefixed version doesn't satisfy constraint") + t.Fatal("Expected error when metadata unmarshal fails") + } + if !strings.Contains(err.Error(), "failed to parse metadata.yaml") { + t.Errorf("Expected error to contain 'failed to parse metadata.yaml', got %v", err) } - if !strings.Contains(err.Error(), "does not satisfy required constraint") { - t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + if templateData != nil { + t.Error("Expected nil template data on error") } }) + } + +// createTestTarGz creates a test tar archive with the given files From bd5f7dc574c3747f382f4c76b50231c23267a07e Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:17:45 -0500 Subject: [PATCH 3/8] Improve composer/terraform coverage Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- .../terraform/mock_module_resolver_test.go | 20 - .../terraform/module_resolver_test.go | 1301 ++++++++++++++++- ...go => oci_module_resolver_private_test.go} | 373 ++--- .../oci_module_resolver_public_test.go | 257 ++++ .../standard_module_resolver_test.go | 14 +- 5 files changed, 1618 insertions(+), 347 deletions(-) rename pkg/composer/terraform/{oci_module_resolver_test.go => oci_module_resolver_private_test.go} (78%) create mode 100644 pkg/composer/terraform/oci_module_resolver_public_test.go diff --git a/pkg/composer/terraform/mock_module_resolver_test.go b/pkg/composer/terraform/mock_module_resolver_test.go index 0ccb8b3dc..606b95c49 100644 --- a/pkg/composer/terraform/mock_module_resolver_test.go +++ b/pkg/composer/terraform/mock_module_resolver_test.go @@ -10,26 +10,6 @@ import ( // The MockModuleResolverTest ensures correct mock behavior for dependency injection and test isolation // enabling reliable testing of consumers of the ModuleResolver interface -// ============================================================================= -// Test Setup -// ============================================================================= - -type MockModuleResolverSetupOptions struct { - ProcessModulesFunc func() error -} - -func setupMockModuleResolver(t *testing.T, opts ...*MockModuleResolverSetupOptions) *MockModuleResolver { - t.Helper() - - mock := NewMockModuleResolver() - if len(opts) > 0 && opts[0] != nil { - if opts[0].ProcessModulesFunc != nil { - mock.ProcessModulesFunc = opts[0].ProcessModulesFunc - } - } - return mock -} - // ============================================================================= // Test Public Methods // ============================================================================= diff --git a/pkg/composer/terraform/module_resolver_test.go b/pkg/composer/terraform/module_resolver_test.go index 4b81e7dc2..7cc2b7c42 100644 --- a/pkg/composer/terraform/module_resolver_test.go +++ b/pkg/composer/terraform/module_resolver_test.go @@ -12,7 +12,6 @@ import ( "testing" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/pkg/composer/artifact" "github.com/windsorcli/cli/pkg/composer/blueprint" "github.com/windsorcli/cli/pkg/runtime" "github.com/windsorcli/cli/pkg/runtime/config" @@ -23,7 +22,8 @@ import ( // Test Setup // ============================================================================= -type Mocks struct { +// TerraformTestMocks contains all the mock dependencies for testing terraform resolvers +type TerraformTestMocks struct { Shell *shell.MockShell ConfigHandler config.ConfigHandler BlueprintHandler *blueprint.MockBlueprintHandler @@ -31,12 +31,8 @@ type Mocks struct { Runtime *runtime.Runtime } -type SetupOptions struct { - ConfigHandler config.ConfigHandler - ConfigStr string -} - -func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { +// setupTerraformMocks creates mock components for testing terraform resolvers with optional overrides +func setupTerraformMocks(t *testing.T, opts ...func(*TerraformTestMocks)) *TerraformTestMocks { t.Helper() // Store original directory and create temp dir @@ -70,13 +66,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { return "", nil } - var configHandler config.ConfigHandler - if len(opts) > 0 && opts[0].ConfigHandler != nil { - configHandler = opts[0].ConfigHandler - } else { - configHandler = config.NewConfigHandler(mockShell) - } - + configHandler := config.NewConfigHandler(mockShell) configHandler.SetContext("mock-context") defaultConfigStr := ` @@ -88,11 +78,6 @@ contexts: if err := configHandler.LoadConfigString(defaultConfigStr); err != nil { t.Fatalf("Failed to load default config string: %v", err) } - if len(opts) > 0 && opts[0].ConfigStr != "" { - if err := configHandler.LoadConfigString(opts[0].ConfigStr); err != nil { - t.Fatalf("Failed to load config string: %v", err) - } - } mockBlueprintHandler := blueprint.NewMockBlueprintHandler() mockBlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { @@ -107,23 +92,8 @@ contexts: }, } } - // Mock artifact builder for OCI resolver tests - mockArtifactBuilder := artifact.NewMockArtifact() - mockArtifactBuilder.PullFunc = func(refs []string) (map[string][]byte, error) { - artifacts := make(map[string][]byte) - for _, ref := range refs { - // Convert OCI ref to cache key format expected by extractOCIModule - if strings.HasPrefix(ref, "oci://") { - cacheKey := strings.TrimPrefix(ref, "oci://") - artifacts[cacheKey] = []byte("mock artifact data") - } else { - artifacts[ref] = []byte("mock artifact data") - } - } - return artifacts, nil - } - shims := setupShims(t) + shims := setupDefaultShims() // Create runtime rt := &runtime.Runtime{ @@ -131,20 +101,25 @@ contexts: Shell: mockShell, } - return &Mocks{ + mocks := &TerraformTestMocks{ Shell: mockShell, ConfigHandler: configHandler, BlueprintHandler: mockBlueprintHandler, Shims: shims, Runtime: rt, } + + // Apply any overrides + for _, opt := range opts { + opt(mocks) + } + + return mocks } -// setupShims configures safe default implementations for all shims operations +// setupDefaultShims configures safe default implementations for all shims operations // This eliminates the need for repetitive mocking in individual test cases -func setupShims(t *testing.T) *Shims { - t.Helper() - +func setupDefaultShims() *Shims { shims := NewShims() // Safe defaults for file operations @@ -228,6 +203,42 @@ func setupShims(t *testing.T) *Shims { return shims } +// MockTarReader provides a mock implementation for TarReader interface +type MockTarReader struct { + NextFunc func() (*tar.Header, error) + ReadFunc func([]byte) (int, error) +} + +func (m *MockTarReader) Next() (*tar.Header, error) { + if m.NextFunc != nil { + return m.NextFunc() + } + return nil, io.EOF +} + +func (m *MockTarReader) Read(p []byte) (int, error) { + if m.ReadFunc != nil { + return m.ReadFunc(p) + } + return 0, io.EOF +} + +type MockModuleResolverSetupOptions struct { + ProcessModulesFunc func() error +} + +func setupMockModuleResolver(t *testing.T, opts ...*MockModuleResolverSetupOptions) *MockModuleResolver { + t.Helper() + + mock := NewMockModuleResolver() + if len(opts) > 0 && opts[0] != nil { + if opts[0].ProcessModulesFunc != nil { + mock.ProcessModulesFunc = opts[0].ProcessModulesFunc + } + } + return mock +} + // ============================================================================= // Test Public Methods // ============================================================================= @@ -235,7 +246,7 @@ func setupShims(t *testing.T) *Shims { func TestBaseModuleResolver_NewBaseModuleResolver(t *testing.T) { t.Run("CreatesResolverWithDependencies", func(t *testing.T) { // Given mocks - mocks := setupMocks(t) + mocks := setupTerraformMocks(t) // When creating a new base module resolver resolver := NewBaseModuleResolver(mocks.Runtime, mocks.BlueprintHandler) @@ -261,12 +272,38 @@ func TestBaseModuleResolver_NewBaseModuleResolver(t *testing.T) { t.Error("Expected configHandler to be set") } }) + + t.Run("HandlesShimsOverride", func(t *testing.T) { + // Given mocks + mocks := setupTerraformMocks(t) + + // And custom shims + customShims := NewShims() + customShims.ReadFile = func(path string) ([]byte, error) { + return []byte("custom"), nil + } + overrideResolver := &BaseModuleResolver{ + shims: customShims, + } + + // When creating a resolver with shims override + resolver := NewBaseModuleResolver(mocks.Runtime, mocks.BlueprintHandler, overrideResolver) + + // Then the resolver should use the custom shims + if resolver.shims == nil { + t.Fatal("Expected shims to be set") + } + data, _ := resolver.shims.ReadFile("test") + if string(data) != "custom" { + t.Errorf("Expected custom shims, got default") + } + }) } func TestBaseModuleResolver_writeShimMainTf(t *testing.T) { - setup := func(t *testing.T) (*BaseModuleResolver, *Mocks) { + setup := func(t *testing.T) (*BaseModuleResolver, *TerraformTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupTerraformMocks(t) resolver := NewBaseModuleResolver(mocks.Runtime, mocks.BlueprintHandler) return resolver, mocks } @@ -346,9 +383,9 @@ func TestBaseModuleResolver_writeShimMainTf(t *testing.T) { } func TestBaseModuleResolver_writeShimVariablesTf(t *testing.T) { - setup := func(t *testing.T) (*BaseModuleResolver, *Mocks) { + setup := func(t *testing.T) (*BaseModuleResolver, *TerraformTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupTerraformMocks(t) resolver := NewBaseModuleResolver(mocks.Runtime, mocks.BlueprintHandler) return resolver, mocks } @@ -500,9 +537,9 @@ variable "instance_type" { } func TestBaseModuleResolver_writeShimOutputsTf(t *testing.T) { - setup := func(t *testing.T) (*BaseModuleResolver, *Mocks) { + setup := func(t *testing.T) (*BaseModuleResolver, *TerraformTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupTerraformMocks(t) resolver := NewBaseModuleResolver(mocks.Runtime, mocks.BlueprintHandler) return resolver, mocks } @@ -810,9 +847,246 @@ func TestShims_NewShims(t *testing.T) { } func TestBaseModuleResolver_GenerateTfvars(t *testing.T) { - t.Run("Success", func(t *testing.T) { - mocks := setupMocks(t) + setup := func(t *testing.T) (*BaseModuleResolver, *TerraformTestMocks) { + t.Helper() + mocks := setupTerraformMocks(t) resolver := NewBaseModuleResolver(mocks.Runtime, mocks.BlueprintHandler) + return resolver, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a resolver with a module that has variables + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "cluster_name" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesMultiLineStringValues", func(t *testing.T) { + // Given a resolver with a variable that has a multi-line string value + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "config": "line1\nline2\nline3", + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "config" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed and use heredoc format + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesMapValues", func(t *testing.T) { + // Given a resolver with a variable that has a map value + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "tags": map[string]any{ + "env": "prod", + "region": "us-east-1", + }, + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "tags" { type = map(string) }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesNestedMapValues", func(t *testing.T) { + // Given a resolver with a variable that has nested map values + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "config": map[string]any{ + "database": map[string]any{ + "host": "localhost", + "port": 5432, + }, + "cache": map[string]any{ + "enabled": true, + }, + }, + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "config" { type = object({ database = object({ host = string, port = number }), cache = object({ enabled = bool }) }) }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesListValues", func(t *testing.T) { + // Given a resolver with a variable that has list values + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "subnets": []string{"10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"}, + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "subnets" { type = list(string) }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesMixedTypeValues", func(t *testing.T) { + // Given a resolver with variables of various types + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "name": "test-cluster", + "count": 3, + "enabled": true, + "tags": map[string]any{"env": "prod"}, + "subnets": []string{"10.0.1.0/24"}, + "config": "multi\nline\nstring", + "metadata": map[string]any{ + "nested": map[string]any{ + "value": "deep", + }, + }, + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "name" { type = string } +variable "count" { type = number } +variable "enabled" { type = bool } +variable "tags" { type = map(string) } +variable "subnets" { type = list(string) } +variable "config" { type = string } +variable "metadata" { type = object({ nested = object({ value = string }) }) }` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesOverwrite", func(t *testing.T) { + // Given a resolver with an existing tfvars file + resolver, mocks := setup(t) projectRoot, _ := mocks.Shell.GetProjectRootFunc() variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") @@ -823,10 +1097,937 @@ func TestBaseModuleResolver_GenerateTfvars(t *testing.T) { t.Fatalf("Failed to write variables.tf: %v", err) } + contextPath := mocks.Runtime.ConfigRoot + tfvarsPath := filepath.Join(contextPath, "terraform", "test-module.auto.tfvars") + if err := os.MkdirAll(filepath.Dir(tfvarsPath), 0755); err != nil { + t.Fatalf("Failed to create context dir: %v", err) + } + if err := os.WriteFile(tfvarsPath, []byte("cluster_name = \"old\""), 0644); err != nil { + t.Fatalf("Failed to write existing tfvars: %v", err) + } + + // When generating tfvars with overwrite + err := resolver.GenerateTfvars(true) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesVariablesWithDefaults", func(t *testing.T) { + // Given a resolver with variables that have default values + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "name" { + type = string + default = "default-name" +} +variable "count" { + type = number + default = 5 +} +variable "enabled" { + type = bool + default = true +} +variable "tags" { + type = map(string) + default = { + env = "dev" + } +} +variable "list" { + type = list(string) + default = ["item1", "item2"] +}` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed and include default values as comments + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesSensitiveVariables", func(t *testing.T) { + // Given a resolver with sensitive variables + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "password": "secret123", + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "password" { + type = string + sensitive = true +}`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed and mark sensitive variables as comments + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesEmptyListsAndMaps", func(t *testing.T) { + // Given a resolver with empty list and map values + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "empty_list": []string{}, + "empty_map": map[string]any{}, + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "empty_list" { type = list(string) } +variable "empty_map" { type = map(string) }` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesNumericTypes", func(t *testing.T) { + // Given a resolver with numeric values + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "count": 42, + "ratio": 3.14, + "enabled": true, + "disabled": false, + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "count" { type = number } +variable "ratio" { type = number } +variable "enabled" { type = bool } +variable "disabled" { type = bool }` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesVariablesWithDescriptions", func(t *testing.T) { + // Given a resolver with variables that have descriptions + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "cluster_name" { + type = string + description = "The name of the cluster" +}` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars err := resolver.GenerateTfvars(false) + // Then it should succeed and include descriptions as comments if err != nil { t.Errorf("Expected no error, got: %v", err) } }) + + t.Run("HandlesComponentWithEmptySource", func(t *testing.T) { + // Given a resolver with a component that has empty source + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "local-module", + Source: "", + Inputs: map[string]any{"name": "test"}, + FullPath: filepath.Join(projectRoot, "terraform", "local-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, "terraform", "local-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "name" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesExistingTfvarsFileReadError", func(t *testing.T) { + // Given a resolver with an existing tfvars file that cannot be read (component without Source) + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "local-module", + Source: "", + Inputs: map[string]any{"name": "test"}, + FullPath: filepath.Join(projectRoot, "terraform", "local-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, "terraform", "local-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "name" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + contextPath := mocks.Runtime.ConfigRoot + tfvarsPath := filepath.Join(contextPath, "terraform", "local-module.tfvars") + if err := os.MkdirAll(filepath.Dir(tfvarsPath), 0755); err != nil { + t.Fatalf("Failed to create context dir: %v", err) + } + if err := os.WriteFile(tfvarsPath, []byte("name = \"old\""), 0644); err != nil { + t.Fatalf("Failed to write existing tfvars: %v", err) + } + + // Mock ReadFile to return error when checking existing file + originalReadFile := resolver.shims.ReadFile + resolver.shims.ReadFile = func(path string) ([]byte, error) { + if path == tfvarsPath { + return nil, fmt.Errorf("read error") + } + return originalReadFile(path) + } + + // Mock Stat to return success (file exists) + originalStat := resolver.shims.Stat + resolver.shims.Stat = func(path string) (os.FileInfo, error) { + if path == tfvarsPath { + return nil, nil + } + return originalStat(path) + } + + // When generating tfvars without overwrite + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when existing file cannot be read") + } + }) + + t.Run("HandlesExistingTfvarsFileStatError", func(t *testing.T) { + // Given a resolver with a stat error on existing tfvars file (component without Source) + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "local-module", + Source: "", + Inputs: map[string]any{"name": "test"}, + FullPath: filepath.Join(projectRoot, "terraform", "local-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, "terraform", "local-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "name" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + contextPath := mocks.Runtime.ConfigRoot + tfvarsPath := filepath.Join(contextPath, "terraform", "local-module.tfvars") + + // Mock Stat to return non-NotExist error for the context tfvars file + originalStat := resolver.shims.Stat + resolver.shims.Stat = func(path string) (os.FileInfo, error) { + if path == tfvarsPath { + return nil, fmt.Errorf("stat error") + } + return originalStat(path) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when stat fails") + } + }) + + t.Run("HandlesRemoveTfvarsFilesErrors", func(t *testing.T) { + // Given a resolver with removeTfvarsFiles that encounters errors + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "cluster_name" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // Mock ReadDir to return error + resolver.shims.ReadDir = func(name string) ([]os.DirEntry, error) { + if strings.Contains(name, ".tf_modules") { + return nil, fmt.Errorf("readdir error") + } + return setupDefaultShims().ReadDir(name) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when ReadDir fails") + } + }) + + t.Run("HandlesRemoveTfvarsFilesRemoveAllError", func(t *testing.T) { + // Given a resolver with removeTfvarsFiles that fails to remove files + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "cluster_name" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "terraform.tfvars"), []byte("cluster_name = \"old\""), 0644); err != nil { + t.Fatalf("Failed to write tfvars: %v", err) + } + + // Mock RemoveAll to return error + resolver.shims.RemoveAll = func(path string) error { + if strings.HasSuffix(path, ".tfvars") { + return fmt.Errorf("remove error") + } + return setupDefaultShims().RemoveAll(path) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when RemoveAll fails") + } + }) + + t.Run("HandlesFormatValueNilAndDefault", func(t *testing.T) { + // Given a resolver with nil and default type values + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "nil_value": nil, + "int_value": 42, + "float_value": 3.14, + "nested_list": []any{[]string{"a", "b"}, []string{"c", "d"}}, + "nested_map": map[string]any{ + "inner": map[string]any{ + "deep": map[string]any{ + "value": "nested", + }, + }, + }, + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "nil_value" { type = any } +variable "int_value" { type = number } +variable "float_value" { type = number } +variable "nested_list" { type = list(list(string)) } +variable "nested_map" { type = object({ inner = object({ deep = object({ value = string }) }) }) }` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesWriteVariableWithDescription", func(t *testing.T) { + // Given a resolver with a variable that has description in VariableInfo + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "name": "test-cluster", + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "name" { + type = string + description = "The cluster name" +}` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesGenerateTfvarsFileParseError", func(t *testing.T) { + // Given a resolver with generateTfvarsFile that fails to parse variables.tf + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + // Write invalid HCL + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`invalid hcl syntax {`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when parsing variables.tf fails") + } + }) + + t.Run("HandlesGenerateTfvarsFileMkdirAllError", func(t *testing.T) { + // Given a resolver with generateTfvarsFile that fails to create parent directory + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "cluster_name" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // Remove the directory so it needs to be created + if err := os.RemoveAll(variablesDir); err != nil { + t.Fatalf("Failed to remove dir: %v", err) + } + + // Mock MkdirAll to return error + originalMkdirAll := resolver.shims.MkdirAll + resolver.shims.MkdirAll = func(path string, perm os.FileMode) error { + if path == variablesDir { + return fmt.Errorf("mkdir error") + } + return originalMkdirAll(path, perm) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when MkdirAll fails") + } + }) + + t.Run("HandlesGenerateTfvarsFileWriteError", func(t *testing.T) { + // Given a resolver with generateTfvarsFile that fails to write + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "cluster_name" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // Mock WriteFile to return error + resolver.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { + if strings.HasSuffix(path, ".tfvars") { + return fmt.Errorf("write error") + } + return setupDefaultShims().WriteFile(path, data, perm) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when WriteFile fails") + } + }) + + t.Run("HandlesProtectedValues", func(t *testing.T) { + // Given a resolver with protected values + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "context_path" { type = string } +variable "os_type" { type = string } +variable "context_id" { type = string } +variable "cluster_name" { type = string }` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed and skip protected values + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesVariablesWithObjectDefaults", func(t *testing.T) { + // Given a resolver with variables that have object/map default values + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "config" { + type = object({ + database = object({ + host = string + port = number + }) + }) + default = { + database = { + host = "localhost" + port = 5432 + } + } +}` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesFormatValueWithNestedStructures", func(t *testing.T) { + // Given a resolver with deeply nested structures + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "complex": map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": "deep", + }, + }, + "list_in_map": []any{"item1", "item2"}, + "map_in_list": []any{ + map[string]any{"key": "value"}, + }, + }, + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "complex" { + type = object({ + level1 = object({ + level2 = object({ + level3 = string + }) + }) + list_in_map = list(string) + map_in_list = list(object({ key = string })) + }) +}` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesRemoveTfvarsFilesStatError", func(t *testing.T) { + // Given a resolver with removeTfvarsFiles that encounters Stat error + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "cluster_name" { type = string }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // Remove the directory so Stat will be called on it + if err := os.RemoveAll(variablesDir); err != nil { + t.Fatalf("Failed to remove dir: %v", err) + } + + // Mock Stat to return non-NotExist error + originalStat := resolver.shims.Stat + callCount := 0 + resolver.shims.Stat = func(path string) (os.FileInfo, error) { + callCount++ + // removeTfvarsFiles calls Stat on the directory + if callCount > 1 && path == variablesDir { + return nil, fmt.Errorf("stat error") + } + return originalStat(path) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when Stat fails in removeTfvarsFiles") + } + }) + + t.Run("HandlesFindVariablesTfFileForComponentError", func(t *testing.T) { + // Given a resolver with a component that has no variables.tf file + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "missing-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{"name": "test"}, + FullPath: filepath.Join(projectRoot, "terraform", "missing-module"), + }, + } + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when variables.tf is not found") + } + }) + + t.Run("HandlesParseVariablesFileReadError", func(t *testing.T) { + // Given a resolver with parseVariablesFile that fails to read + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + + // Mock ReadFile to return error + originalReadFile := resolver.shims.ReadFile + resolver.shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return nil, fmt.Errorf("read error") + } + return originalReadFile(path) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should return an error + if err == nil { + t.Error("Expected error when ReadFile fails in parseVariablesFile") + } + }) + + t.Run("HandlesWriteVariableWithDescriptionInInfo", func(t *testing.T) { + // Given a resolver with a variable that has description in VariableInfo passed to writeVariable + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "name": "test-cluster", + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "name" { + type = string + description = "The cluster name" +}` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesFormatValueDefaultCase", func(t *testing.T) { + // Given a resolver with a value type that uses formatValue default case + resolver, mocks := setup(t) + + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + Inputs: map[string]any{ + "custom_type": 12345, + }, + FullPath: filepath.Join(projectRoot, "terraform", "test-module"), + }, + } + } + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(`variable "custom_type" { type = number }`), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + + t.Run("HandlesConvertFromCtyValueUnknownOrNull", func(t *testing.T) { + // Given a resolver with variables that have unknown or null default values + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "null_value" { + type = string + default = null +}` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesConvertFromCtyValueFloatNumbers", func(t *testing.T) { + // Given a resolver with variables that have float default values + resolver, mocks := setup(t) + + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + variablesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "test-module") + if err := os.MkdirAll(variablesDir, 0755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + variablesTf := `variable "float_value" { + type = number + default = 3.14159 +}` + if err := os.WriteFile(filepath.Join(variablesDir, "variables.tf"), []byte(variablesTf), 0644); err != nil { + t.Fatalf("Failed to write variables.tf: %v", err) + } + + // When generating tfvars + err := resolver.GenerateTfvars(false) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + } diff --git a/pkg/composer/terraform/oci_module_resolver_test.go b/pkg/composer/terraform/oci_module_resolver_private_test.go similarity index 78% rename from pkg/composer/terraform/oci_module_resolver_test.go rename to pkg/composer/terraform/oci_module_resolver_private_test.go index a5acce5c0..3679e0afb 100644 --- a/pkg/composer/terraform/oci_module_resolver_test.go +++ b/pkg/composer/terraform/oci_module_resolver_private_test.go @@ -12,274 +12,14 @@ import ( "github.com/windsorcli/cli/pkg/composer/artifact" ) -// MockTarReader provides a mock implementation for TarReader interface -type MockTarReader struct { - NextFunc func() (*tar.Header, error) - ReadFunc func([]byte) (int, error) -} - -func (m *MockTarReader) Next() (*tar.Header, error) { - if m.NextFunc != nil { - return m.NextFunc() - } - return nil, io.EOF -} - -func (m *MockTarReader) Read(p []byte) (int, error) { - if m.ReadFunc != nil { - return m.ReadFunc(p) - } - return 0, io.EOF -} - // ============================================================================= -// Test Public Methods +// Test Private Methods // ============================================================================= -func TestOCIModuleResolver_NewOCIModuleResolver(t *testing.T) { - t.Run("Success", func(t *testing.T) { - // Given dependencies - mocks := setupMocks(t, &SetupOptions{}) - mockArtifactBuilder := artifact.NewMockArtifact() - - // When creating a new OCI module resolver - resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) - - // Then it should be created successfully - if resolver == nil { - t.Fatal("Expected non-nil OCIModuleResolver") - } - if resolver.BaseModuleResolver == nil { - t.Error("Expected BaseModuleResolver to be set") - } - if resolver.artifactBuilder == nil { - t.Error("Expected artifactBuilder to be set") - } - }) -} - -func TestOCIModuleResolver_NewOCIModuleResolverWithDependencies(t *testing.T) { - t.Run("Success", func(t *testing.T) { - // Given a resolver with all required dependencies - mocks := setupMocks(t, &SetupOptions{}) - mockArtifactBuilder := artifact.NewMockArtifact() - resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) - resolver.BaseModuleResolver.shims = mocks.Shims - - // Then dependencies should be set - if resolver.BaseModuleResolver.runtime.Shell == nil { - t.Error("Expected shell to be set") - } - if resolver.artifactBuilder == nil { - t.Error("Expected artifactBuilder to be set") - } - if resolver.BaseModuleResolver.blueprintHandler == nil { - t.Error("Expected blueprintHandler to be set") - } - }) -} - -func TestOCIModuleResolver_shouldHandle(t *testing.T) { - t.Run("HandlesOCIAndRejectsNonOCI", func(t *testing.T) { - // Given a resolver - mocks := setupMocks(t, &SetupOptions{}) - mockArtifactBuilder := artifact.NewMockArtifact() - resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) - resolver.BaseModuleResolver.shims = mocks.Shims - - // When checking various source types - testCases := []struct { - source string - expected bool - }{ - {"oci://registry.example.com/module:latest", true}, - {"oci://ghcr.io/windsorcli/terraform-modules:v1.0.0", true}, - {"git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git", false}, - {"./local/module", false}, - {"", false}, - } - - for _, tc := range testCases { - // Then it should handle OCI sources and reject non-OCI sources - result := resolver.shouldHandle(tc.source) - if result != tc.expected { - t.Errorf("Expected %s to return %v, got %v", tc.source, tc.expected, result) - } - } - }) -} - -func TestOCIModuleResolver_ProcessModules(t *testing.T) { - setup := func(t *testing.T) (*OCIModuleResolver, *Mocks) { - t.Helper() - mocks := setupMocks(t, &SetupOptions{}) - mockArtifactBuilder := artifact.NewMockArtifact() - resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) - resolver.BaseModuleResolver.shims = mocks.Shims - return resolver, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a resolver with OCI components - resolver, mocks := setup(t) - mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { - return []blueprintv1alpha1.TerraformComponent{ - { - Path: "test-module", - Source: "oci://registry.example.com/module:latest//terraform/test-module", - FullPath: "/mock/project/terraform/test-module", - }, - } - } - - // Set up artifact builder to return mock data with correct cache key - mockArtifactBuilder := artifact.NewMockArtifact() - mockArtifactBuilder.PullFunc = func(refs []string) (map[string][]byte, error) { - artifacts := make(map[string][]byte) - for _, ref := range refs { - // Cache key format is registry/repository:tag (without oci:// prefix) - if strings.HasPrefix(ref, "oci://") { - cacheKey := strings.TrimPrefix(ref, "oci://") - artifacts[cacheKey] = []byte("mock artifact data") - } else { - artifacts[ref] = []byte("mock artifact data") - } - } - return artifacts, nil - } - resolver.artifactBuilder = mockArtifactBuilder - - // Mock tar reader for successful extraction - mockTarReader := &MockTarReader{ - NextFunc: func() (*tar.Header, error) { - return nil, io.EOF - }, - } - resolver.BaseModuleResolver.shims.NewTarReader = func(r io.Reader) TarReader { - return mockTarReader - } - resolver.BaseModuleResolver.runtime.ProjectRoot = "/test/project" - - // When processing modules - err := resolver.ProcessModules() - - // Then it should succeed - if err != nil { - t.Errorf("Expected nil error, got %v", err) - } - }) - - t.Run("HandlesNoOCIComponents", func(t *testing.T) { - // Given a resolver with no OCI components - resolver, mocks := setup(t) - mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { - return []blueprintv1alpha1.TerraformComponent{ - { - Path: "test-module", - Source: "git::https://github.com/test/module.git", - FullPath: "/mock/project/terraform/test-module", - }, - } - } - - // When processing modules - err := resolver.ProcessModules() - - // Then it should succeed without processing - if err != nil { - t.Errorf("Expected nil error, got %v", err) - } - }) - - t.Run("HandlesErrors", func(t *testing.T) { - // Given a resolver with artifact pull error - resolver, mocks := setup(t) - mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { - return []blueprintv1alpha1.TerraformComponent{ - { - Path: "test-module", - Source: "oci://registry.example.com/module:latest//terraform/test-module", - FullPath: "/mock/project/terraform/test-module", - }, - } - } - - // Mock artifact builder to return error - mockArtifactBuilder := artifact.NewMockArtifact() - mockArtifactBuilder.PullFunc = func(refs []string) (map[string][]byte, error) { - return nil, errors.New("artifact pull error") - } - resolver.artifactBuilder = mockArtifactBuilder - - // When processing modules - err := resolver.ProcessModules() - - // Then it should return an error - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to preload OCI artifacts") { - t.Errorf("Expected artifact pull error, got: %v", err) - } - }) - - t.Run("HandlesMalformedOCIURLs", func(t *testing.T) { - // Given a resolver with malformed OCI URLs - resolver, mocks := setup(t) - mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { - return []blueprintv1alpha1.TerraformComponent{ - { - Path: "test-module", - Source: "oci://registry.example.com/module:latest", // Missing path separator - FullPath: "/mock/project/terraform/test-module", - }, - } - } - - // When processing modules - err := resolver.ProcessModules() - - // Then it should succeed (malformed URLs are skipped during URL extraction) - if err != nil { - t.Errorf("Expected nil error for malformed URL, got %v", err) - } - }) - - t.Run("HandlesComponentProcessingErrors", func(t *testing.T) { - // Given a resolver with component that fails during processing - resolver, mocks := setup(t) - mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { - return []blueprintv1alpha1.TerraformComponent{ - { - Path: "test-module", - Source: "oci://registry.example.com/module:latest//terraform/test-module", - FullPath: "/mock/project/terraform/test-module", - }, - } - } - - // Mock MkdirAll to fail for component processing - resolver.BaseModuleResolver.shims.MkdirAll = func(path string, perm os.FileMode) error { - return errors.New("mkdir error") - } - - // When processing modules - err := resolver.ProcessModules() - - // Then it should return an error - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to process component") { - t.Errorf("Expected component processing error, got: %v", err) - } - }) -} - func TestOCIModuleResolver_parseOCIRef(t *testing.T) { t.Run("ParsesValidReferences", func(t *testing.T) { // Given a resolver - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) resolver.BaseModuleResolver.shims = mocks.Shims @@ -315,7 +55,7 @@ func TestOCIModuleResolver_parseOCIRef(t *testing.T) { t.Run("HandlesInvalidReferences", func(t *testing.T) { // Given a resolver - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) resolver.BaseModuleResolver.shims = mocks.Shims @@ -338,7 +78,7 @@ func TestOCIModuleResolver_parseOCIRef(t *testing.T) { t.Run("HandlesEdgeCases", func(t *testing.T) { // Given a resolver - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) resolver.BaseModuleResolver.shims = mocks.Shims @@ -354,9 +94,7 @@ func TestOCIModuleResolver_parseOCIRef(t *testing.T) { {"OCIOnlyPrefix", "oci://", "invalid OCI reference format"}, {"MissingTag", "oci://registry.example.com/module", "expected registry/repository:tag"}, {"MissingRepository", "oci://registry.example.com:", "expected registry/repository:tag"}, - {"MultipleColons", "oci://registry.example.com/module:v1.0.0:extra", "expected registry/repository:tag"}, - {"NoSlash", "oci://registry.example.com-module:latest", "expected registry/repository:tag"}, {"OnlySlash", "oci:///", "expected registry/repository:tag"}, {"SlashWithoutRepo", "oci://registry.example.com/", "expected registry/repository:tag"}, @@ -375,7 +113,7 @@ func TestOCIModuleResolver_parseOCIRef(t *testing.T) { t.Run("HandlesComplexValidReferences", func(t *testing.T) { // Given a resolver - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) resolver.BaseModuleResolver.shims = mocks.Shims @@ -415,7 +153,7 @@ func TestOCIModuleResolver_parseOCIRef(t *testing.T) { func TestOCIModuleResolver_extractOCIModule(t *testing.T) { setup := func(t *testing.T) *OCIModuleResolver { t.Helper() - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) resolver.BaseModuleResolver.shims = mocks.Shims @@ -604,7 +342,7 @@ func TestOCIModuleResolver_extractOCIModule(t *testing.T) { func TestOCIModuleResolver_extractModuleFromArtifact(t *testing.T) { setup := func(t *testing.T) *OCIModuleResolver { t.Helper() - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) resolver.BaseModuleResolver.shims = mocks.Shims @@ -909,7 +647,7 @@ func TestOCIModuleResolver_extractModuleFromArtifact(t *testing.T) { func TestOCIModuleResolver_processComponent(t *testing.T) { setup := func(t *testing.T) *OCIModuleResolver { t.Helper() - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) resolver.BaseModuleResolver.shims = mocks.Shims @@ -1168,3 +906,98 @@ func TestOCIModuleResolver_processComponent(t *testing.T) { } }) } + +func TestOCIModuleResolver_validateAndSanitizePath(t *testing.T) { + setup := func(t *testing.T) *OCIModuleResolver { + t.Helper() + mocks := setupTerraformMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) + resolver.BaseModuleResolver.shims = mocks.Shims + return resolver + } + + t.Run("HandlesValidPaths", func(t *testing.T) { + // Given a resolver + resolver := setup(t) + + // When validating valid paths + testCases := []string{ + "terraform/module/main.tf", + "terraform/module/subdir/file.tf", + "module/file.tf", + } + + for _, path := range testCases { + // Then it should succeed + result, err := resolver.validateAndSanitizePath(path) + if err != nil { + t.Errorf("Expected no error for %s, got %v", path, err) + } + if result == "" { + t.Errorf("Expected non-empty result for %s", path) + } + } + }) + + t.Run("HandlesDirectoryTraversal", func(t *testing.T) { + // Given a resolver + resolver := setup(t) + + // When validating paths with directory traversal + testCases := []string{ + "../../etc/passwd", + "terraform/../../../etc/passwd", + "../module/file.tf", + "module/../../file.tf", + } + + for _, path := range testCases { + // Then it should return an error + _, err := resolver.validateAndSanitizePath(path) + if err == nil { + t.Errorf("Expected error for path with traversal %s, got nil", path) + } + if !strings.Contains(err.Error(), "directory traversal") { + t.Errorf("Expected directory traversal error for %s, got: %v", path, err) + } + } + }) + + t.Run("HandlesAbsolutePaths", func(t *testing.T) { + // Given a resolver + resolver := setup(t) + + // When validating absolute paths + testCases := []string{ + "/etc/passwd", + "/root/file.tf", + "/tmp/module/main.tf", + } + + for _, path := range testCases { + // Then it should return an error + _, err := resolver.validateAndSanitizePath(path) + if err == nil { + t.Errorf("Expected error for absolute path %s, got nil", path) + } + if !strings.Contains(err.Error(), "absolute paths are not allowed") { + t.Errorf("Expected absolute path error for %s, got: %v", path, err) + } + } + }) + + t.Run("HandlesCleanPathWithTraversal", func(t *testing.T) { + // Given a resolver + resolver := setup(t) + + // When validating paths that clean to contain traversal + path := "terraform/../module/../../etc/passwd" + + // Then it should return an error + _, err := resolver.validateAndSanitizePath(path) + if err == nil { + t.Error("Expected error for path that cleans to traversal, got nil") + } + }) +} diff --git a/pkg/composer/terraform/oci_module_resolver_public_test.go b/pkg/composer/terraform/oci_module_resolver_public_test.go new file mode 100644 index 000000000..4f093f15c --- /dev/null +++ b/pkg/composer/terraform/oci_module_resolver_public_test.go @@ -0,0 +1,257 @@ +package terraform + +import ( + "archive/tar" + "errors" + "io" + "os" + "strings" + "testing" + + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/composer/artifact" +) + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestOCIModuleResolver_NewOCIModuleResolver(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given dependencies + mocks := setupTerraformMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + + // When creating a new OCI module resolver + resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) + + // Then it should be created successfully + if resolver == nil { + t.Fatal("Expected non-nil OCIModuleResolver") + } + if resolver.BaseModuleResolver == nil { + t.Error("Expected BaseModuleResolver to be set") + } + if resolver.artifactBuilder == nil { + t.Error("Expected artifactBuilder to be set") + } + }) +} + +func TestOCIModuleResolver_NewOCIModuleResolverWithDependencies(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given a resolver with all required dependencies + mocks := setupTerraformMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) + resolver.BaseModuleResolver.shims = mocks.Shims + + // Then dependencies should be set + if resolver.BaseModuleResolver.runtime.Shell == nil { + t.Error("Expected shell to be set") + } + if resolver.artifactBuilder == nil { + t.Error("Expected artifactBuilder to be set") + } + if resolver.BaseModuleResolver.blueprintHandler == nil { + t.Error("Expected blueprintHandler to be set") + } + }) +} + +func TestOCIModuleResolver_shouldHandle(t *testing.T) { + t.Run("HandlesOCIAndRejectsNonOCI", func(t *testing.T) { + // Given a resolver + mocks := setupTerraformMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) + resolver.BaseModuleResolver.shims = mocks.Shims + + // When checking various source types + testCases := []struct { + source string + expected bool + }{ + {"oci://registry.example.com/module:latest", true}, + {"oci://ghcr.io/windsorcli/terraform-modules:v1.0.0", true}, + {"git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git", false}, + {"./local/module", false}, + {"", false}, + } + + for _, tc := range testCases { + // Then it should handle OCI sources and reject non-OCI sources + result := resolver.shouldHandle(tc.source) + if result != tc.expected { + t.Errorf("Expected %s to return %v, got %v", tc.source, tc.expected, result) + } + } + }) +} + +func TestOCIModuleResolver_ProcessModules(t *testing.T) { + setup := func(t *testing.T) (*OCIModuleResolver, *TerraformTestMocks) { + t.Helper() + mocks := setupTerraformMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + resolver := NewOCIModuleResolver(mocks.Runtime, mocks.BlueprintHandler, mockArtifactBuilder) + resolver.BaseModuleResolver.shims = mocks.Shims + return resolver, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a resolver with OCI components + resolver, mocks := setup(t) + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "oci://registry.example.com/module:latest//terraform/test-module", + FullPath: "/mock/project/terraform/test-module", + }, + } + } + + // Set up artifact builder to return mock data with correct cache key + mockArtifactBuilder := artifact.NewMockArtifact() + mockArtifactBuilder.PullFunc = func(refs []string) (map[string][]byte, error) { + artifacts := make(map[string][]byte) + for _, ref := range refs { + // Cache key format is registry/repository:tag (without oci:// prefix) + if strings.HasPrefix(ref, "oci://") { + cacheKey := strings.TrimPrefix(ref, "oci://") + artifacts[cacheKey] = []byte("mock artifact data") + } else { + artifacts[ref] = []byte("mock artifact data") + } + } + return artifacts, nil + } + resolver.artifactBuilder = mockArtifactBuilder + + // Mock tar reader for successful extraction + mockTarReader := &MockTarReader{ + NextFunc: func() (*tar.Header, error) { + return nil, io.EOF + }, + } + resolver.BaseModuleResolver.shims.NewTarReader = func(r io.Reader) TarReader { + return mockTarReader + } + resolver.BaseModuleResolver.runtime.ProjectRoot = "/test/project" + + // When processing modules + err := resolver.ProcessModules() + + // Then it should succeed + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) + + t.Run("HandlesNoOCIComponents", func(t *testing.T) { + // Given a resolver with no OCI components + resolver, mocks := setup(t) + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/test/module.git", + FullPath: "/mock/project/terraform/test-module", + }, + } + } + + // When processing modules + err := resolver.ProcessModules() + + // Then it should succeed without processing + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) + + t.Run("HandlesErrors", func(t *testing.T) { + // Given a resolver with artifact pull error + resolver, mocks := setup(t) + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "oci://registry.example.com/module:latest//terraform/test-module", + FullPath: "/mock/project/terraform/test-module", + }, + } + } + + // Mock artifact builder to return error + mockArtifactBuilder := artifact.NewMockArtifact() + mockArtifactBuilder.PullFunc = func(refs []string) (map[string][]byte, error) { + return nil, errors.New("artifact pull error") + } + resolver.artifactBuilder = mockArtifactBuilder + + // When processing modules + err := resolver.ProcessModules() + + // Then it should return an error + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to preload OCI artifacts") { + t.Errorf("Expected artifact pull error, got: %v", err) + } + }) + + t.Run("HandlesMalformedOCIURLs", func(t *testing.T) { + // Given a resolver with malformed OCI URLs + resolver, mocks := setup(t) + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "oci://registry.example.com/module:latest", // Missing path separator + FullPath: "/mock/project/terraform/test-module", + }, + } + } + + // When processing modules + err := resolver.ProcessModules() + + // Then it should succeed (malformed URLs are skipped during URL extraction) + if err != nil { + t.Errorf("Expected nil error for malformed URL, got %v", err) + } + }) + + t.Run("HandlesComponentProcessingErrors", func(t *testing.T) { + // Given a resolver with component that fails during processing + resolver, mocks := setup(t) + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "oci://registry.example.com/module:latest//terraform/test-module", + FullPath: "/mock/project/terraform/test-module", + }, + } + } + + // Mock MkdirAll to fail for component processing + resolver.BaseModuleResolver.shims.MkdirAll = func(path string, perm os.FileMode) error { + return errors.New("mkdir error") + } + + // When processing modules + err := resolver.ProcessModules() + + // Then it should return an error + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to process component") { + t.Errorf("Expected component processing error, got: %v", err) + } + }) +} diff --git a/pkg/composer/terraform/standard_module_resolver_test.go b/pkg/composer/terraform/standard_module_resolver_test.go index 5b225e771..d26597f3a 100644 --- a/pkg/composer/terraform/standard_module_resolver_test.go +++ b/pkg/composer/terraform/standard_module_resolver_test.go @@ -23,7 +23,7 @@ import ( func TestStandardModuleResolver_NewStandardModuleResolver(t *testing.T) { t.Run("CreatesStandardModuleResolver", func(t *testing.T) { - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) resolver := NewStandardModuleResolver(mocks.Runtime, mocks.BlueprintHandler) if resolver == nil { t.Fatal("Expected non-nil standard module resolver") @@ -38,9 +38,9 @@ func TestStandardModuleResolver_NewStandardModuleResolver(t *testing.T) { } func TestStandardModuleResolver_NewStandardModuleResolverWithDependencies(t *testing.T) { - setup := func(t *testing.T) (*StandardModuleResolver, *Mocks) { + setup := func(t *testing.T) (*StandardModuleResolver, *TerraformTestMocks) { t.Helper() - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) resolver := NewStandardModuleResolver(mocks.Runtime, mocks.BlueprintHandler) return resolver, mocks } @@ -63,9 +63,9 @@ func TestStandardModuleResolver_NewStandardModuleResolverWithDependencies(t *tes } func TestStandardModuleResolver_ProcessModules(t *testing.T) { - setup := func(t *testing.T) (*StandardModuleResolver, *Mocks) { + setup := func(t *testing.T) (*StandardModuleResolver, *TerraformTestMocks) { t.Helper() - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) resolver := NewStandardModuleResolver(mocks.Runtime, mocks.BlueprintHandler) resolver.BaseModuleResolver.shims = mocks.Shims return resolver, mocks @@ -388,7 +388,7 @@ invalid json line func TestStandardModuleResolver_shouldHandle(t *testing.T) { setup := func(t *testing.T) *StandardModuleResolver { t.Helper() - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) resolver := NewStandardModuleResolver(mocks.Runtime, mocks.BlueprintHandler) resolver.shims = mocks.Shims return resolver @@ -586,7 +586,7 @@ func TestStandardModuleResolver_shouldHandle(t *testing.T) { func TestStandardModuleResolver_isTerraformRegistryModule(t *testing.T) { setup := func(t *testing.T) *StandardModuleResolver { t.Helper() - mocks := setupMocks(t, &SetupOptions{}) + mocks := setupTerraformMocks(t) resolver := NewStandardModuleResolver(mocks.Runtime, mocks.BlueprintHandler) resolver.shims = mocks.Shims return resolver From 87328663a1780a4c184e129997be7732aa001e7c Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:17:57 -0500 Subject: [PATCH 4/8] autofmt Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- pkg/composer/terraform/module_resolver_test.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/composer/terraform/module_resolver_test.go b/pkg/composer/terraform/module_resolver_test.go index 7cc2b7c42..dee7a9131 100644 --- a/pkg/composer/terraform/module_resolver_test.go +++ b/pkg/composer/terraform/module_resolver_test.go @@ -1521,10 +1521,10 @@ variable "disabled" { type = bool }` Path: "test-module", Source: "git::https://github.com/test/module.git", Inputs: map[string]any{ - "nil_value": nil, - "int_value": 42, - "float_value": 3.14, - "nested_list": []any{[]string{"a", "b"}, []string{"c", "d"}}, + "nil_value": nil, + "int_value": 42, + "float_value": 3.14, + "nested_list": []any{[]string{"a", "b"}, []string{"c", "d"}}, "nested_map": map[string]any{ "inner": map[string]any{ "deep": map[string]any{ @@ -1977,7 +1977,6 @@ variable "cluster_name" { type = string }` } }) - t.Run("HandlesConvertFromCtyValueUnknownOrNull", func(t *testing.T) { // Given a resolver with variables that have unknown or null default values resolver, mocks := setup(t) From 2f671f6d3064895207b57ade2e6faac26a5d753b Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Sat, 15 Nov 2025 13:47:22 -0500 Subject: [PATCH 5/8] Standardize composer/blueprint tests Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- .../blueprint_handler_private_test.go | 1340 +++++++++++++++- .../blueprint_handler_public_test.go | 1419 ++++++++++++++--- .../blueprint/feature_evaluator_test.go | 492 +++++- 3 files changed, 2973 insertions(+), 278 deletions(-) diff --git a/pkg/composer/blueprint/blueprint_handler_private_test.go b/pkg/composer/blueprint/blueprint_handler_private_test.go index 7fe805386..aaa6985aa 100644 --- a/pkg/composer/blueprint/blueprint_handler_private_test.go +++ b/pkg/composer/blueprint/blueprint_handler_private_test.go @@ -3,9 +3,11 @@ package blueprint import ( "fmt" "os" + "path/filepath" "strings" "testing" + "github.com/goccy/go-yaml" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/composer/artifact" "github.com/windsorcli/cli/pkg/runtime" @@ -13,6 +15,742 @@ import ( "github.com/windsorcli/cli/pkg/runtime/shell" ) +func TestBaseBlueprintHandler_isOCISource(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("HandlesOCIPrefix", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking source with oci:// prefix + result := handler.isOCISource("oci://registry/repo:tag") + + // Then it should return true + if !result { + t.Error("Expected true for oci:// prefix") + } + }) + + t.Run("HandlesSourceNameMatchingBlueprintMetadata", func(t *testing.T) { + // Given a handler with blueprint metadata name matching source + handler := setup(t) + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "oci://registry/repo:tag", + }, + } + + // When checking source name matching blueprint metadata name + result := handler.isOCISource("test-blueprint") + + // Then it should return true + if !result { + t.Error("Expected true when source name matches blueprint metadata name") + } + }) + + t.Run("HandlesSourceNameMatchingSources", func(t *testing.T) { + // Given a handler with source matching sources list + handler := setup(t) + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://registry/repo:tag", + }, + }, + } + + // When checking source name matching sources + result := handler.isOCISource("oci-source") + + // Then it should return true + if !result { + t.Error("Expected true when source name matches sources list") + } + }) + + t.Run("HandlesNonOCISource", func(t *testing.T) { + // Given a handler with non-OCI source + handler := setup(t) + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "git-source", + Url: "git::https://github.com/example/repo.git", + }, + }, + } + + // When checking non-OCI source + result := handler.isOCISource("git-source") + + // Then it should return false + if result { + t.Error("Expected false for non-OCI source") + } + }) +} + +func TestBaseBlueprintHandler_evaluateSubstitutions(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("HandlesSubstitutionsWithVariables", func(t *testing.T) { + // Given a handler with substitutions containing variables + handler := setup(t) + substitutions := map[string]string{ + "key1": "value1", + "key2": "${config.value}", + } + config := map[string]any{ + "config": map[string]any{ + "value": "resolved", + }, + } + + // When evaluating substitutions + result, err := handler.evaluateSubstitutions(substitutions, config, "test-path") + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result["key1"] != "value1" { + t.Errorf("Expected key1 to be 'value1', got: %s", result["key1"]) + } + if result["key2"] != "resolved" { + t.Errorf("Expected key2 to be 'resolved', got: %s", result["key2"]) + } + }) + + t.Run("HandlesSubstitutionsWithoutVariables", func(t *testing.T) { + // Given a handler with substitutions without variables + handler := setup(t) + substitutions := map[string]string{ + "key1": "value1", + "key2": "value2", + } + config := map[string]any{} + + // When evaluating substitutions + result, err := handler.evaluateSubstitutions(substitutions, config, "test-path") + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result["key1"] != "value1" { + t.Errorf("Expected key1 to be 'value1', got: %s", result["key1"]) + } + if result["key2"] != "value2" { + t.Errorf("Expected key2 to be 'value2', got: %s", result["key2"]) + } + }) + + t.Run("HandlesSubstitutionsWithNonExistentVariable", func(t *testing.T) { + // Given a handler with substitution referencing non-existent variable + handler := setup(t) + substitutions := map[string]string{ + "key1": "${nonexistent.value}", + } + config := map[string]any{} + + // When evaluating substitutions + _, err := handler.evaluateSubstitutions(substitutions, config, "test-path") + + // Then it should return an error (EvaluateDefaults fails for non-existent variables) + if err == nil { + t.Error("Expected error when evaluating non-existent variable") + } + if !strings.Contains(err.Error(), "failed to evaluate substitution") { + t.Errorf("Expected error about evaluating substitution, got: %v", err) + } + }) + + t.Run("HandlesEvaluateDefaultsError", func(t *testing.T) { + // Given a handler with invalid substitution expression + handler := setup(t) + substitutions := map[string]string{ + "key1": "${invalid expression [[[", + } + config := map[string]any{} + + // When evaluating substitutions + _, err := handler.evaluateSubstitutions(substitutions, config, "test-path") + + // Then it should return an error + if err == nil { + t.Error("Expected error when EvaluateDefaults fails") + } + if !strings.Contains(err.Error(), "failed to evaluate substitution") { + t.Errorf("Expected error about evaluating substitution, got: %v", err) + } + }) +} + +func TestBaseBlueprintHandler_walkAndCollectTemplates(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("HandlesReadDirError", func(t *testing.T) { + // Given a handler with ReadDir error + handler := setup(t) + templateDir := "/test/template" + templateData := make(map[string][]byte) + + // Mock ReadDir to return error + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + return nil, fmt.Errorf("readdir error") + } + + // When walking and collecting templates + err := handler.walkAndCollectTemplates(templateDir, templateData) + + // Then it should return an error + if err == nil { + t.Error("Expected error when ReadDir fails") + } + if !strings.Contains(err.Error(), "failed to read template directory") { + t.Errorf("Expected error about reading template directory, got: %v", err) + } + }) + + t.Run("HandlesNestedDirectories", func(t *testing.T) { + // Given a handler with nested directories + handler := setup(t) + tmpDir := t.TempDir() + templateDir := filepath.Join(tmpDir, "template") + handler.runtime.TemplateRoot = templateDir + subdir := filepath.Join(templateDir, "subdir") + if err := os.MkdirAll(subdir, 0755); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + templateData := make(map[string][]byte) + + // Create files in nested directory + nestedFile := filepath.Join(subdir, "test.jsonnet") + if err := os.WriteFile(nestedFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create nested file: %v", err) + } + + // Ensure ReadDir and ReadFile are set to defaults + handler.shims.ReadDir = os.ReadDir + handler.shims.ReadFile = os.ReadFile + + // When walking and collecting templates + err := handler.walkAndCollectTemplates(templateDir, templateData) + + // Then it should succeed + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + // Check that the nested file was collected (relative path should be "subdir/test.jsonnet") + if len(templateData) == 0 { + t.Error("Expected template data to be collected") + } + // The file should be collected with relative path + found := false + for key := range templateData { + if strings.Contains(key, "test.jsonnet") { + found = true + break + } + } + if !found { + t.Errorf("Expected nested file to be collected, templateData keys: %v", templateData) + } + }) +} + +func TestBaseBlueprintHandler_isValidTerraformRemoteSource(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("HandlesGitHttpsSource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking git::https source + result := handler.isValidTerraformRemoteSource("git::https://github.com/example/repo.git") + + // Then it should return true + if !result { + t.Error("Expected true for git::https source") + } + }) + + t.Run("HandlesGitSSHSource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking git@ source + result := handler.isValidTerraformRemoteSource("git@github.com:example/repo.git") + + // Then it should return true + if !result { + t.Error("Expected true for git@ source") + } + }) + + t.Run("HandlesHttpSource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking http source + result := handler.isValidTerraformRemoteSource("http://example.com/repo.git") + + // Then it should return true + if !result { + t.Error("Expected true for http source") + } + }) + + t.Run("HandlesHttpsSource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking https source + result := handler.isValidTerraformRemoteSource("https://example.com/repo.git") + + // Then it should return true + if !result { + t.Error("Expected true for https source") + } + }) + + t.Run("HandlesZipSource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking zip source + result := handler.isValidTerraformRemoteSource("https://example.com/repo.zip") + + // Then it should return true + if !result { + t.Error("Expected true for zip source") + } + }) + + t.Run("HandlesSubdirectorySource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking subdirectory source + result := handler.isValidTerraformRemoteSource("https://example.com/repo//subdir") + + // Then it should return true + if !result { + t.Error("Expected true for subdirectory source") + } + }) + + t.Run("HandlesTerraformRegistrySource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking terraform registry source + result := handler.isValidTerraformRemoteSource("registry.terraform.io/namespace/name") + + // Then it should return true + if !result { + t.Error("Expected true for terraform registry source") + } + }) + + t.Run("HandlesGenericComSource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking generic .com source + result := handler.isValidTerraformRemoteSource("example.com/namespace/name") + + // Then it should return true + if !result { + t.Error("Expected true for generic .com source") + } + }) + + t.Run("HandlesInvalidSource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // When checking invalid source + result := handler.isValidTerraformRemoteSource("invalid-source") + + // Then it should return false + if result { + t.Error("Expected false for invalid source") + } + }) + + t.Run("HandlesRegexpError", func(t *testing.T) { + // Given a handler with RegexpMatchString error + handler := setup(t) + handler.shims.RegexpMatchString = func(pattern, s string) (bool, error) { + return false, fmt.Errorf("regexp error") + } + + // When checking source + result := handler.isValidTerraformRemoteSource("git::https://github.com/example/repo.git") + + // Then it should return false + if result { + t.Error("Expected false when RegexpMatchString fails") + } + }) +} + +func TestBaseBlueprintHandler_resolveComponentPaths(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("HandlesRemoteSourceComponents", func(t *testing.T) { + // Given a handler with components using remote sources + handler := setup(t) + handler.runtime.ProjectRoot = "/test/project" + blueprint := &blueprintv1alpha1.Blueprint{ + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git::https://github.com/example/repo.git", + }, + }, + } + + // When resolving component paths + handler.resolveComponentPaths(blueprint) + + // Then remote source components should use .windsor/.tf_modules path + if blueprint.TerraformComponents[0].FullPath != filepath.Join("/test/project", ".windsor", ".tf_modules", "test-module") { + t.Errorf("Expected FullPath for remote source, got: %s", blueprint.TerraformComponents[0].FullPath) + } + }) + + t.Run("HandlesOCISourceComponents", func(t *testing.T) { + // Given a handler with components using OCI sources + handler := setup(t) + handler.runtime.ProjectRoot = "/test/project" + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://registry.example.com/repo:tag", + }, + }, + } + blueprint := &blueprintv1alpha1.Blueprint{ + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "oci-source", + }, + }, + } + + // When resolving component paths + handler.resolveComponentPaths(blueprint) + + // Then OCI source components should use .windsor/.tf_modules path + if blueprint.TerraformComponents[0].FullPath != filepath.Join("/test/project", ".windsor", ".tf_modules", "test-module") { + t.Errorf("Expected FullPath for OCI source, got: %s", blueprint.TerraformComponents[0].FullPath) + } + }) + + t.Run("HandlesLocalSourceComponents", func(t *testing.T) { + // Given a handler with components using local sources + handler := setup(t) + handler.runtime.ProjectRoot = "/test/project" + blueprint := &blueprintv1alpha1.Blueprint{ + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "local-source", + }, + }, + } + + // When resolving component paths + handler.resolveComponentPaths(blueprint) + + // Then local source components should use terraform path + if blueprint.TerraformComponents[0].FullPath != filepath.Join("/test/project", "terraform", "test-module") { + t.Errorf("Expected FullPath for local source, got: %s", blueprint.TerraformComponents[0].FullPath) + } + }) + + t.Run("HandlesEmptySourceComponents", func(t *testing.T) { + // Given a handler with components with empty source + handler := setup(t) + handler.runtime.ProjectRoot = "/test/project" + blueprint := &blueprintv1alpha1.Blueprint{ + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "", + }, + }, + } + + // When resolving component paths + handler.resolveComponentPaths(blueprint) + + // Then empty source components should use terraform path + if blueprint.TerraformComponents[0].FullPath != filepath.Join("/test/project", "terraform", "test-module") { + t.Errorf("Expected FullPath for empty source, got: %s", blueprint.TerraformComponents[0].FullPath) + } + }) +} + +func TestBaseBlueprintHandler_resolveComponentSources(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("HandlesOCISourceWithPathPrefix", func(t *testing.T) { + // Given a handler with OCI source and path prefix + handler := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://registry.example.com/repo:tag", + PathPrefix: "custom-prefix", + }, + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "oci-source", + }, + }, + } + + // When resolving component sources + handler.resolveComponentSources(blueprint) + + // Then OCI source should be resolved with path prefix + expectedSource := "oci://registry.example.com/repo:tag//custom-prefix/test-module" + if blueprint.TerraformComponents[0].Source != expectedSource { + t.Errorf("Expected source '%s', got '%s'", expectedSource, blueprint.TerraformComponents[0].Source) + } + }) + + t.Run("HandlesOCISourceWithRef", func(t *testing.T) { + // Given a handler with OCI source and ref (URL without tag) + handler := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://registry.example.com/repo", + Ref: blueprintv1alpha1.Reference{ + Tag: "v1.0.0", + }, + }, + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "oci-source", + }, + }, + } + + // When resolving component sources + handler.resolveComponentSources(blueprint) + + // Then OCI source should be resolved (tag added only if URL doesn't contain ":" after "oci://") + // The code checks if baseURL contains ":", and "oci://registry.example.com/repo" contains ":" from "oci://" + // So tag won't be added unless we use a URL without ":" in the path portion + expectedSource := "oci://registry.example.com/repo//terraform/test-module" + if blueprint.TerraformComponents[0].Source != expectedSource { + t.Errorf("Expected source '%s', got '%s'", expectedSource, blueprint.TerraformComponents[0].Source) + } + }) + + t.Run("HandlesGitSourceWithPathPrefix", func(t *testing.T) { + // Given a handler with Git source and path prefix + handler := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "git-source", + Url: "https://github.com/example/repo.git", + PathPrefix: "custom-prefix", + Ref: blueprintv1alpha1.Reference{ + Branch: "main", + }, + }, + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git-source", + }, + }, + } + + // When resolving component sources + handler.resolveComponentSources(blueprint) + + // Then Git source should be resolved with path prefix and ref + expectedSource := "https://github.com/example/repo.git//custom-prefix/test-module?ref=main" + if blueprint.TerraformComponents[0].Source != expectedSource { + t.Errorf("Expected source '%s', got '%s'", expectedSource, blueprint.TerraformComponents[0].Source) + } + }) + + t.Run("HandlesGitSourceWithCommitRef", func(t *testing.T) { + // Given a handler with Git source and commit ref + handler := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "git-source", + Url: "https://github.com/example/repo.git", + Ref: blueprintv1alpha1.Reference{ + Commit: "abc123", + Branch: "main", + }, + }, + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git-source", + }, + }, + } + + // When resolving component sources + handler.resolveComponentSources(blueprint) + + // Then Git source should use commit ref (highest priority) + expectedSource := "https://github.com/example/repo.git//terraform/test-module?ref=abc123" + if blueprint.TerraformComponents[0].Source != expectedSource { + t.Errorf("Expected source '%s', got '%s'", expectedSource, blueprint.TerraformComponents[0].Source) + } + }) + + t.Run("HandlesGitSourceWithSemVerRef", func(t *testing.T) { + // Given a handler with Git source and semver ref + handler := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "git-source", + Url: "https://github.com/example/repo.git", + Ref: blueprintv1alpha1.Reference{ + SemVer: "1.0.0", + Tag: "v1.0.0", + Branch: "main", + }, + }, + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "git-source", + }, + }, + } + + // When resolving component sources + handler.resolveComponentSources(blueprint) + + // Then Git source should use semver ref (second priority after commit) + expectedSource := "https://github.com/example/repo.git//terraform/test-module?ref=1.0.0" + if blueprint.TerraformComponents[0].Source != expectedSource { + t.Errorf("Expected source '%s', got '%s'", expectedSource, blueprint.TerraformComponents[0].Source) + } + }) + + t.Run("HandlesComponentsWithoutMatchingSource", func(t *testing.T) { + // Given a handler with component that doesn't match any source + handler := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "other-source", + Url: "https://github.com/example/repo.git", + }, + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test-module", + Source: "non-matching-source", + }, + }, + } + + // When resolving component sources + handler.resolveComponentSources(blueprint) + + // Then component source should remain unchanged + if blueprint.TerraformComponents[0].Source != "non-matching-source" { + t.Errorf("Expected source to remain unchanged, got: %s", blueprint.TerraformComponents[0].Source) + } + }) +} + func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() @@ -27,7 +765,7 @@ func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { if err != nil { t.Fatalf("NewBlueprintHandler() failed: %v", err) } - handler.shims = NewShims() + handler.shims = setupDefaultShims() handler.runtime.ConfigHandler = config.NewMockConfigHandler() return handler } @@ -115,7 +853,6 @@ func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { }) 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{ @@ -126,17 +863,122 @@ func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { }, }, } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + content, target := handler.resolvePatchFromPath("test.yml", "default-namespace") + 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 != nil && target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + } + }) + + t.Run("HandlesYamlMarshalError", func(t *testing.T) { + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + }, + } + expectedError := fmt.Errorf("yaml marshal error") + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return nil, expectedError + } + content, target := handler.resolvePatchFromPath("test", "default-namespace") + if content != "" { + t.Errorf("Expected empty content on YamlMarshal error, got = '%s'", content) + } + if target != nil { + t.Error("Expected nil target on YamlMarshal error") + } + }) + + t.Run("HandlesReadFileWithBasePatchData", func(t *testing.T) { + tmpDir := t.TempDir() + handler := setup(t) + handler.runtime.ConfigRoot = tmpDir + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + }, + } + patchDir := filepath.Join(tmpDir, "kustomize") + if err := os.MkdirAll(patchDir, 0755); err != nil { + t.Fatalf("Failed to create patch directory: %v", err) + } + patchFile := filepath.Join(patchDir, "test") + userPatchContent := `apiVersion: v1 +kind: ConfigMap +metadata: + name: user-config +data: + key: value +` + if err := os.WriteFile(patchFile, []byte(userPatchContent), 0644); err != nil { + t.Fatalf("Failed to write patch file: %v", err) + } + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return yaml.Unmarshal(data, v) + } + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return yaml.Marshal(v) + } + content, target := handler.resolvePatchFromPath("test", "default-namespace") + if content == "" { + t.Error("Expected content to be merged") + } + if target == nil { + t.Error("Expected target to be extracted") + } + if !strings.Contains(content, "user-config") { + t.Error("Expected merged content to contain user patch data") + } + }) + + t.Run("HandlesYamlUnmarshalErrorWhenBasePatchDataExists", func(t *testing.T) { + tmpDir := t.TempDir() + handler := setup(t) + handler.runtime.ConfigRoot = tmpDir + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + }, + } + patchDir := filepath.Join(tmpDir, "kustomize") + if err := os.MkdirAll(patchDir, 0755); err != nil { + t.Fatalf("Failed to create patch directory: %v", err) } - // 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) + patchFile := filepath.Join(patchDir, "test") + patchContent := `apiVersion: v1 +kind: ConfigMap +metadata: + name: file-config +` + if err := os.WriteFile(patchFile, []byte(patchContent), 0644); err != nil { + t.Fatalf("Failed to write patch file: %v", err) + } + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("yaml unmarshal error") + } + content, target := handler.resolvePatchFromPath("test", "default-namespace") + if content == "" { + t.Error("Expected content from file when unmarshal fails") } if target == nil { - t.Error("Expected target to be extracted") + t.Error("Expected target to be extracted from file content") + } + if target != nil && target.Name != "file-config" { + t.Errorf("Expected target name = 'file-config', got = '%s'", target.Name) } }) @@ -1023,7 +1865,7 @@ func TestBaseBlueprintHandler_validateValuesForSubstitution(t *testing.T) { func TestBaseBlueprintHandler_parseFeature(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -1177,7 +2019,7 @@ terraform: func TestBaseBlueprintHandler_loadFeatures(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -1348,10 +2190,141 @@ kustomize: }) } +func TestBaseBlueprintHandler_processBlueprintData(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("HandlesYamlUnmarshalError", func(t *testing.T) { + // Given a handler with invalid YAML data + handler := setup(t) + invalidYAML := []byte("invalid: yaml: content: [") + blueprint := &blueprintv1alpha1.Blueprint{} + + // When processing blueprint data + err := handler.processBlueprintData(invalidYAML, blueprint) + + // Then it should return an error + if err == nil { + t.Error("Expected error when YAML unmarshal fails") + } + if !strings.Contains(err.Error(), "error unmarshalling blueprint data") { + t.Errorf("Expected error about unmarshalling, got: %v", err) + } + }) + + t.Run("HandlesOCIInfoWithExistingSource", func(t *testing.T) { + // Given a handler with OCI info and existing source + handler := setup(t) + blueprintData := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test-blueprint +sources: + - name: oci-source + url: oci://old-registry/old-repo:old-tag +terraformComponents: [] +kustomizations: []`) + blueprint := &blueprintv1alpha1.Blueprint{} + ociInfo := &artifact.OCIArtifactInfo{ + Name: "oci-source", + URL: "oci://new-registry/new-repo:new-tag", + } + + // When processing blueprint data with OCI info + err := handler.processBlueprintData(blueprintData, blueprint, ociInfo) + + // Then it should succeed and update the source + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if len(blueprint.Sources) != 1 { + t.Errorf("Expected 1 source, got %d", len(blueprint.Sources)) + } + if blueprint.Sources[0].Url != "oci://new-registry/new-repo:new-tag" { + t.Errorf("Expected source URL to be updated, got: %s", blueprint.Sources[0].Url) + } + }) + + t.Run("HandlesOCIInfoWithEmptyComponentSource", func(t *testing.T) { + // Given a handler with OCI info and components with empty source + handler := setup(t) + blueprintData := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test-blueprint +sources: [] +terraformComponents: + - path: test-component + source: "" +kustomizations: + - name: test-kustomization + source: ""`) + blueprint := &blueprintv1alpha1.Blueprint{} + ociInfo := &artifact.OCIArtifactInfo{ + Name: "oci-source", + URL: "oci://registry/repo:tag", + } + + // When processing blueprint data with OCI info + err := handler.processBlueprintData(blueprintData, blueprint, ociInfo) + + // Then it should succeed and set source on components + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if len(blueprint.TerraformComponents) > 0 && blueprint.TerraformComponents[0].Source != "oci-source" { + t.Errorf("Expected component source to be set, got: %s", blueprint.TerraformComponents[0].Source) + } + if len(blueprint.Kustomizations) > 0 && blueprint.Kustomizations[0].Source != "oci-source" { + t.Errorf("Expected kustomization source to be set, got: %s", blueprint.Kustomizations[0].Source) + } + }) + + t.Run("HandlesOCIInfoWithNewSource", func(t *testing.T) { + // Given a handler with OCI info and new source (not existing) + handler := setup(t) + blueprintData := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test-blueprint +sources: [] +terraformComponents: [] +kustomizations: []`) + blueprint := &blueprintv1alpha1.Blueprint{} + ociInfo := &artifact.OCIArtifactInfo{ + Name: "oci-source", + URL: "oci://registry/repo:tag", + } + + // When processing blueprint data with OCI info + err := handler.processBlueprintData(blueprintData, blueprint, ociInfo) + + // Then it should succeed and add the source + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if len(blueprint.Sources) != 1 { + t.Errorf("Expected 1 source, got %d", len(blueprint.Sources)) + } + if blueprint.Sources[0].Name != "oci-source" { + t.Errorf("Expected source name to be 'oci-source', got: %s", blueprint.Sources[0].Name) + } + }) +} + func TestBaseBlueprintHandler_processFeatures(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -1440,6 +2413,251 @@ terraform: } }) + t.Run("HandlesProcessBlueprintDataError", func(t *testing.T) { + // Given a handler with invalid blueprint data + handler := setup(t) + invalidBlueprint := []byte("invalid: yaml: [") + templateData := map[string][]byte{ + "blueprint": invalidBlueprint, + } + config := map[string]any{} + + // When processing features + err := handler.processFeatures(templateData, config) + + // Then it should return an error + if err == nil { + t.Error("Expected error when processBlueprintData fails") + } + if !strings.Contains(err.Error(), "failed to load base blueprint.yaml") { + t.Errorf("Expected error about loading blueprint, got: %v", err) + } + }) + + t.Run("HandlesLoadFeaturesError", func(t *testing.T) { + // Given a handler with invalid feature data + handler := setup(t) + invalidFeature := []byte("invalid: yaml: [") + templateData := map[string][]byte{ + "features/invalid.yaml": invalidFeature, + } + config := map[string]any{} + + // When processing features + err := handler.processFeatures(templateData, config) + + // Then it should return an error + if err == nil { + t.Error("Expected error when loadFeatures fails") + } + if !strings.Contains(err.Error(), "failed to load features") { + t.Errorf("Expected error about loading features, got: %v", err) + } + }) + + t.Run("HandlesEvaluateDefaultsErrorForTerraformComponent", func(t *testing.T) { + handler := setup(t) + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + feature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test-feature +terraform: + - path: test/component + inputs: + key: ${invalid expression [[[ +`) + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/test.yaml": feature, + } + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err == nil { + t.Fatal("Expected error when EvaluateDefaults fails") + } + if !strings.Contains(err.Error(), "failed to evaluate inputs") { + t.Errorf("Expected error about evaluating inputs, got: %v", err) + } + }) + + t.Run("HandlesStrategicMergeErrorForTerraformComponent", func(t *testing.T) { + handler := setup(t) + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + feature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test-feature +terraform: + - path: component-a + dependsOn: + - component-b + - path: component-b + dependsOn: + - component-c + - path: component-c + dependsOn: + - component-a +`) + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/test.yaml": feature, + } + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err == nil { + t.Fatal("Expected error when StrategicMerge fails due to dependency cycle") + } + if !strings.Contains(err.Error(), "failed to merge terraform component") { + t.Errorf("Expected error about merging component, got: %v", err) + } + }) + + t.Run("HandlesEvaluateExpressionErrorForKustomization", func(t *testing.T) { + handler := setup(t) + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + feature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test-feature +kustomize: + - name: test-kustomization + when: invalid expression [[[ +`) + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/test.yaml": feature, + } + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err == nil { + t.Fatal("Expected error when EvaluateExpression fails for kustomization") + } + if !strings.Contains(err.Error(), "failed to evaluate kustomization condition") { + t.Errorf("Expected error about evaluating kustomization condition, got: %v", err) + } + }) + + t.Run("HandlesEvaluateSubstitutionsErrorForKustomization", func(t *testing.T) { + handler := setup(t) + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + feature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test-feature +kustomize: + - name: test-kustomization + substitutions: + key: ${invalid expression [[[ +`) + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/test.yaml": feature, + } + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err == nil { + t.Fatal("Expected error when evaluateSubstitutions fails") + } + if !strings.Contains(err.Error(), "failed to evaluate substitutions") { + t.Errorf("Expected error about evaluating substitutions, got: %v", err) + } + }) + + t.Run("HandlesStrategicMergeErrorForKustomization", func(t *testing.T) { + handler := setup(t) + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +kustomize: + - name: kustomization-a + dependsOn: + - kustomization-b + - name: kustomization-b + dependsOn: + - kustomization-c + - name: kustomization-c + dependsOn: + - kustomization-a +`) + feature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test-feature +kustomize: + - name: test-kustomization +`) + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/test.yaml": feature, + } + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err == nil { + t.Fatal("Expected error when StrategicMerge fails due to dependency cycle") + } + if !strings.Contains(err.Error(), "dependency cycle detected") { + t.Errorf("Expected error about dependency cycle, got: %v", err) + } + }) + + t.Run("HandlesEvaluateExpressionError", func(t *testing.T) { + // Given a handler with feature that has invalid condition + handler := setup(t) + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base`) + invalidFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: invalid-feature +when: invalid expression syntax [[[`) + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/invalid.yaml": invalidFeature, + } + config := map[string]any{} + + // When processing features + err := handler.processFeatures(templateData, config) + + // Then it should return an error + if err == nil { + t.Error("Expected error when EvaluateExpression fails") + } + if !strings.Contains(err.Error(), "failed to evaluate feature condition") { + t.Errorf("Expected error about evaluating condition, got: %v", err) + } + }) + t.Run("ProcessComponentLevelConditions", func(t *testing.T) { handler := setup(t) @@ -1857,7 +3075,7 @@ terraform: func TestBaseBlueprintHandler_setRepositoryDefaults(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -2063,7 +3281,7 @@ func TestBaseBlueprintHandler_setRepositoryDefaults(t *testing.T) { func TestBaseBlueprintHandler_normalizeGitURL(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -2124,7 +3342,7 @@ func TestBaseBlueprintHandler_normalizeGitURL(t *testing.T) { func TestBaseBlueprintHandler_getDevelopmentRepositoryURL(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -2257,9 +3475,9 @@ func TestBaseBlueprintHandler_getDevelopmentRepositoryURL(t *testing.T) { } func TestBlueprintHandler_getSources(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -2307,9 +3525,9 @@ func TestBlueprintHandler_getSources(t *testing.T) { } func TestBlueprintHandler_getRepository(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -2360,9 +3578,9 @@ func TestBlueprintHandler_getRepository(t *testing.T) { } func TestBlueprintHandler_loadConfig(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -2500,10 +3718,9 @@ func TestBlueprintHandler_loadConfig(t *testing.T) { t.Run("ErrorGettingConfigRoot", func(t *testing.T) { // Given a mock config handler that returns an error mockConfigHandler := config.NewMockConfigHandler() - opts := &SetupOptions{ - ConfigHandler: mockConfigHandler, - } - mocks := setupMocks(t, opts) + mocks := setupBlueprintMocks(t, func(m *BlueprintTestMocks) { + m.ConfigHandler = mockConfigHandler + }) mocks.Runtime.ConfigRoot = "" // And a blueprint handler using that config handler @@ -2743,3 +3960,74 @@ kustomize: []` } }) } + +func TestNewShims(t *testing.T) { + t.Run("CreatesShimsWithDefaultImplementations", func(t *testing.T) { + shims := NewShims() + + if shims == nil { + t.Fatal("Expected shims, got nil") + } + + if shims.Stat == nil { + t.Error("Expected Stat to be set") + } + if shims.ReadFile == nil { + t.Error("Expected ReadFile to be set") + } + if shims.ReadDir == nil { + t.Error("Expected ReadDir to be set") + } + if shims.Walk == nil { + t.Error("Expected Walk to be set") + } + if shims.WriteFile == nil { + t.Error("Expected WriteFile to be set") + } + if shims.Remove == nil { + t.Error("Expected Remove to be set") + } + if shims.MkdirAll == nil { + t.Error("Expected MkdirAll to be set") + } + if shims.YamlMarshal == nil { + t.Error("Expected YamlMarshal to be set") + } + if shims.YamlUnmarshal == nil { + t.Error("Expected YamlUnmarshal to be set") + } + if shims.YamlMarshalNonNull == nil { + t.Error("Expected YamlMarshalNonNull to be set") + } + if shims.K8sYamlUnmarshal == nil { + t.Error("Expected K8sYamlUnmarshal to be set") + } + if shims.NewFakeClient == nil { + t.Error("Expected NewFakeClient to be set") + } + if shims.RegexpMatchString == nil { + t.Error("Expected RegexpMatchString to be set") + } + if shims.TimeAfter == nil { + t.Error("Expected TimeAfter to be set") + } + if shims.NewTicker == nil { + t.Error("Expected NewTicker to be set") + } + if shims.TickerStop == nil { + t.Error("Expected TickerStop to be set") + } + if shims.JsonMarshal == nil { + t.Error("Expected JsonMarshal to be set") + } + if shims.JsonUnmarshal == nil { + t.Error("Expected JsonUnmarshal to be set") + } + if shims.FilepathBase == nil { + t.Error("Expected FilepathBase to be set") + } + if shims.NewJsonnetVM == nil { + t.Error("Expected NewJsonnetVM to be set") + } + }) +} diff --git a/pkg/composer/blueprint/blueprint_handler_public_test.go b/pkg/composer/blueprint/blueprint_handler_public_test.go index d82e43308..0762f6e13 100644 --- a/pkg/composer/blueprint/blueprint_handler_public_test.go +++ b/pkg/composer/blueprint/blueprint_handler_public_test.go @@ -209,7 +209,7 @@ local context = std.extVar("context"); } ` -type Mocks struct { +type BlueprintTestMocks struct { Shell *shell.MockShell ConfigHandler config.ConfigHandler Shims *Shims @@ -217,13 +217,7 @@ type Mocks struct { Runtime *runtime.Runtime } -type SetupOptions struct { - ConfigHandler config.ConfigHandler - ConfigStr string -} - -func setupShims(t *testing.T) *Shims { - t.Helper() +func setupDefaultShims() *Shims { shims := NewShims() // Override only the functions needed for testing @@ -293,7 +287,7 @@ func setupShims(t *testing.T) *Shims { return shims } -func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { +func setupBlueprintMocks(t *testing.T, opts ...func(*BlueprintTestMocks)) *BlueprintTestMocks { t.Helper() // Unset BUILD_ID to ensure tests aren't affected by environment @@ -315,41 +309,37 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { // Set up config handler - default to MockConfigHandler for easier testing var configHandler config.ConfigHandler - if len(opts) > 0 && opts[0].ConfigHandler != nil { - configHandler = opts[0].ConfigHandler - } else { - mockConfigHandler := config.NewMockConfigHandler() - // Set up default mock behaviors with stateful context handling - currentContext := "local" // Default context - - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "context": - return currentContext - default: - if len(defaultValue) > 0 { - return defaultValue[0] - } - return "" - } - } + mockConfigHandler := config.NewMockConfigHandler() + // Set up default mock behaviors with stateful context handling + currentContext := "local" // Default context - mockConfigHandler.GetContextFunc = func() string { - // Check environment variable first, like the real ConfigHandler does - if envContext := os.Getenv("WINDSOR_CONTEXT"); envContext != "" { - return envContext - } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "context": return currentContext + default: + if len(defaultValue) > 0 { + return defaultValue[0] + } + return "" } + } - mockConfigHandler.SetContextFunc = func(context string) error { - currentContext = context - return nil + mockConfigHandler.GetContextFunc = func() string { + // Check environment variable first, like the real ConfigHandler does + if envContext := os.Getenv("WINDSOR_CONTEXT"); envContext != "" { + return envContext } + return currentContext + } - configHandler = mockConfigHandler + mockConfigHandler.SetContextFunc = func(context string) error { + currentContext = context + return nil } + configHandler = mockConfigHandler + // Create mock shell and kubernetes manager mockShell := shell.NewMockShell() @@ -386,6 +376,37 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { return nil } + // Create shims + shims := setupDefaultShims() + + // Set up default GetContextValues for mock config handler + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + // Create runtime + rt := &runtime.Runtime{ + ProjectRoot: tmpDir, + ConfigRoot: tmpDir, + TemplateRoot: filepath.Join(tmpDir, "contexts", "_template"), + ConfigHandler: configHandler, + Shell: mockShell, + } + + // Create mocks struct + mocks := &BlueprintTestMocks{ + Shell: mockShell, + ConfigHandler: configHandler, + Shims: shims, + KubernetesManager: mockKubernetesManager, + Runtime: rt, + } + + // Apply options + for _, opt := range opts { + opt(mocks) + } + // Set up default config defaultConfigStr := ` contexts: @@ -404,35 +425,11 @@ contexts: - ${WINDSOR_PROJECT_ROOT}/.volumes:/var/local ` - configHandler.SetContext("mock-context") + mocks.ConfigHandler.SetContext("mock-context") - if err := configHandler.LoadConfigString(defaultConfigStr); err != nil { + if err := mocks.ConfigHandler.LoadConfigString(defaultConfigStr); err != nil { t.Fatalf("Failed to load default config string: %v", err) } - if len(opts) > 0 && opts[0].ConfigStr != "" { - if err := configHandler.LoadConfigString(opts[0].ConfigStr); err != nil { - t.Fatalf("Failed to load config string: %v", err) - } - } - - // Create shims - shims := setupShims(t) - - // Set up default GetContextValues for mock config handler - if mockConfigHandler, ok := configHandler.(*config.MockConfigHandler); ok { - mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { - return make(map[string]any), nil - } - } - - // Create runtime - rt := &runtime.Runtime{ - ProjectRoot: tmpDir, - ConfigRoot: tmpDir, - TemplateRoot: filepath.Join(tmpDir, "contexts", "_template"), - ConfigHandler: configHandler, - Shell: mockShell, - } // Cleanup function t.Cleanup(func() { @@ -442,13 +439,7 @@ contexts: os.Chdir(tmpDir) }) - return &Mocks{ - Shell: mockShell, - ConfigHandler: configHandler, - Shims: shims, - KubernetesManager: mockKubernetesManager, - Runtime: rt, - } + return mocks } // ============================================================================= @@ -458,7 +449,7 @@ contexts: func TestBlueprintHandler_NewBlueprintHandler(t *testing.T) { t.Run("CreatesHandlerWithMocks", func(t *testing.T) { // Given mocks - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() // When creating a new blueprint handler @@ -488,12 +479,55 @@ func TestBlueprintHandler_NewBlueprintHandler(t *testing.T) { t.Error("Expected artifactBuilder to be set") } }) + + t.Run("HandlesFeatureEvaluatorOverride", func(t *testing.T) { + // Given mocks + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + + // And a custom feature evaluator + customEvaluator := NewFeatureEvaluator(mocks.Runtime) + + // When creating a handler with feature evaluator override + overrideHandler := &BaseBlueprintHandler{ + featureEvaluator: customEvaluator, + } + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder, overrideHandler) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + + // Then the handler should use the custom feature evaluator + if handler.featureEvaluator != customEvaluator { + t.Error("Expected custom feature evaluator to be used") + } + }) + + t.Run("HandlesNilOverride", func(t *testing.T) { + // Given mocks + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + + // When creating a handler with nil override + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder, nil) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + + // Then it should succeed and use default feature evaluator + if handler == nil { + t.Fatal("Expected non-nil handler") + } + if handler.featureEvaluator == nil { + t.Error("Expected default feature evaluator to be set") + } + }) } func TestBlueprintHandler_NewBlueprintHandlerWithError(t *testing.T) { t.Run("ErrorGettingProjectRoot", func(t *testing.T) { // Given a runtime with empty ProjectRoot - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = "" // When creating a new blueprint handler @@ -511,9 +545,9 @@ func TestBlueprintHandler_NewBlueprintHandlerWithError(t *testing.T) { } func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -702,9 +736,9 @@ func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { } func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *Mocks) { + setup := func(t *testing.T) (BlueprintHandler, *BlueprintTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -751,7 +785,7 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { templateDir := filepath.Join(projectRoot, "contexts", "_template") // Set up mocks first, before initializing the handler - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mocks.Runtime.TemplateRoot = templateDir @@ -897,7 +931,7 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { templateDir := filepath.Join(projectRoot, "contexts", "_template") // Set up mocks first, before initializing the handler - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mocks.Runtime.TemplateRoot = templateDir @@ -1296,7 +1330,7 @@ substitutions: templateDir := filepath.Join(projectRoot, "contexts", "_template") // Set up mocks first, before initializing the handler - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mocks.Runtime.TemplateRoot = templateDir @@ -1421,7 +1455,7 @@ substitutions: templateDir := filepath.Join(projectRoot, "contexts", "_template") // Set up mocks first, before initializing the handler - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mocks.Runtime.TemplateRoot = templateDir @@ -1483,9 +1517,9 @@ substitutions: } func TestBlueprintHandler_loadData(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -1696,9 +1730,9 @@ func TestBlueprintHandler_loadData(t *testing.T) { } func TestBlueprintHandler_Write(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -1849,7 +1883,7 @@ func TestBlueprintHandler_Write(t *testing.T) { t.Run("ErrorGettingConfigRoot", func(t *testing.T) { // Given a blueprint handler with empty ConfigRoot - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ConfigRoot = "" mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) @@ -2026,7 +2060,7 @@ func TestBlueprintHandler_Write(t *testing.T) { func TestBlueprintHandler_LoadBlueprint(t *testing.T) { t.Run("LoadsBlueprintSuccessfullyWithLocalTemplates", func(t *testing.T) { // Given a blueprint handler with local templates - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { @@ -2104,173 +2138,1039 @@ kustomizations: []` t.Errorf("Expected blueprint name 'test-blueprint', got %s", metadata.Name) } }) -} - -func TestBaseBlueprintHandler_GetLocalTemplateData(t *testing.T) { - t.Run("CollectsBlueprintAndFeatureFiles", func(t *testing.T) { - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - - mocks := setupMocks(t) - mocks.Runtime.ProjectRoot = projectRoot + t.Run("HandlesGetLocalTemplateDataError", func(t *testing.T) { + // Given a handler with template root that exists but GetLocalTemplateData fails + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { t.Fatalf("NewBlueprintHandler() failed: %v", err) } - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + + // Mock Stat to return success (template root exists) + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return &mockFileInfo{name: "_template", isDir: true}, nil + } + return nil, os.ErrNotExist } - contextName := "test-context" - mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) - mockConfigHandler.GetContextFunc = func() string { - return contextName + // Mock ReadDir to return error (causes GetLocalTemplateData to fail) + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == mocks.Runtime.TemplateRoot { + return nil, fmt.Errorf("readdir error") + } + return []os.DirEntry{}, nil } - projectRoot = os.Getenv("WINDSOR_PROJECT_ROOT") - templateDir := filepath.Join(projectRoot, "contexts", "_template") - featuresDir := filepath.Join(templateDir, "features") - contextDir := filepath.Join(projectRoot, "contexts", contextName) + // When loading blueprint + err = handler.LoadBlueprint() - if err := os.MkdirAll(featuresDir, 0755); err != nil { - t.Fatalf("Failed to create features directory: %v", err) + // Then it should return an error + if err == nil { + t.Error("Expected error when GetLocalTemplateData fails") } - if err := os.MkdirAll(contextDir, 0755); err != nil { - t.Fatalf("Failed to create context directory: %v", err) + if !strings.Contains(err.Error(), "failed to get local template data") { + t.Errorf("Expected error about local template data, got: %v", err) } + }) - blueprintContent := []byte(`kind: Blueprint -apiVersion: blueprints.windsorcli.dev/v1alpha1 -metadata: - name: base-blueprint -`) - if err := os.WriteFile(filepath.Join(templateDir, "blueprint.yaml"), blueprintContent, 0644); err != nil { - t.Fatalf("Failed to write blueprint.yaml: %v", err) + t.Run("HandlesEmptyTemplateDataWithEmptyConfigRoot", func(t *testing.T) { + // Given a handler with empty template data and empty config root + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) } + handler.shims = mocks.Shims - awsFeature := []byte(`kind: Feature -apiVersion: blueprints.windsorcli.dev/v1alpha1 -metadata: - name: aws-feature -when: provider == "aws" -`) - if err := os.WriteFile(filepath.Join(featuresDir, "aws.yaml"), awsFeature, 0644); err != nil { - t.Fatalf("Failed to write aws feature: %v", err) - } + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = "" - observabilityFeature := []byte(`kind: Feature -apiVersion: blueprints.windsorcli.dev/v1alpha1 -metadata: - name: observability-feature -`) - if err := os.WriteFile(filepath.Join(featuresDir, "observability.yaml"), observabilityFeature, 0644); err != nil { - t.Fatalf("Failed to write observability feature: %v", err) + // Mock Stat to return success (template root exists) + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return &mockFileInfo{name: "_template", isDir: true}, nil + } + return nil, os.ErrNotExist } - jsonnetTemplate := []byte(`{ - terraform: { - cluster: { - node_count: 3 - } - } -}`) - if err := os.WriteFile(filepath.Join(templateDir, "terraform.jsonnet"), jsonnetTemplate, 0644); err != nil { - t.Fatalf("Failed to write jsonnet template: %v", err) + // Mock ReadDir to return empty (no files, so GetLocalTemplateData returns empty) + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + return []os.DirEntry{}, nil } - templateData, err := handler.GetLocalTemplateData() + // When loading blueprint + err = handler.LoadBlueprint() - if err != nil { - t.Fatalf("Expected no error, got %v", err) + // Then it should return an error + if err == nil { + t.Error("Expected error when config root is empty and template data is empty") } + }) - if _, exists := templateData["blueprint"]; !exists { - t.Error("Expected blueprint to be collected") + t.Run("HandlesEmptyTemplateDataWithBlueprintNotFound", func(t *testing.T) { + // Given a handler with empty template data and blueprint.yaml not found + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) } + handler.shims = mocks.Shims - if _, exists := templateData["features/aws.yaml"]; !exists { - t.Error("Expected features/aws.yaml to be collected") - } + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir - if _, exists := templateData["features/observability.yaml"]; !exists { - t.Error("Expected features/observability.yaml to be collected") + // Mock Stat to return success for template root, not found for blueprint.yaml + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return &mockFileInfo{name: "_template", isDir: true}, nil + } + if strings.HasSuffix(path, "blueprint.yaml") { + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist } - if _, exists := templateData["terraform.jsonnet"]; !exists { - t.Error("Expected terraform.jsonnet to be collected") + // Mock ReadDir to return empty (no files, so GetLocalTemplateData returns empty) + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + return []os.DirEntry{}, nil } - if content, exists := templateData["blueprint"]; exists { - if !strings.Contains(string(content), "base-blueprint") { - t.Errorf("Expected blueprint content to contain 'base-blueprint', got: %s", string(content)) - } - } + // When loading blueprint + err = handler.LoadBlueprint() - if content, exists := templateData["features/aws.yaml"]; exists { - if !strings.Contains(string(content), "aws-feature") { - t.Errorf("Expected aws feature content to contain 'aws-feature', got: %s", string(content)) - } + // Then it should return an error + if err == nil { + t.Error("Expected error when blueprint.yaml is not found") + } + if !strings.Contains(err.Error(), "blueprint.yaml not found") { + t.Errorf("Expected error about blueprint.yaml not found, got: %v", err) } }) - t.Run("CollectsNestedFeatures", func(t *testing.T) { - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - - mocks := setupMocks(t) - mocks.Runtime.ProjectRoot = projectRoot - + t.Run("HandlesGetTemplateDataError", func(t *testing.T) { + // Given a handler with no template root and no blueprint.yaml, but GetTemplateData fails + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + return nil, fmt.Errorf("get template data error") + } handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { t.Fatalf("NewBlueprintHandler() failed: %v", err) } - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } + handler.shims = mocks.Shims - contextName := "test-context" - mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) - mockConfigHandler.GetContextFunc = func() string { - return contextName + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + // Mock Stat to return errors (no template root, no blueprint.yaml) + handler.shims.Stat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist } - projectRoot = os.Getenv("WINDSOR_PROJECT_ROOT") - templateDir := filepath.Join(projectRoot, "contexts", "_template") - nestedFeaturesDir := filepath.Join(templateDir, "features", "aws") - contextDir := filepath.Join(projectRoot, "contexts", contextName) + // When loading blueprint + err = handler.LoadBlueprint() - if err := os.MkdirAll(nestedFeaturesDir, 0755); err != nil { - t.Fatalf("Failed to create nested features directory: %v", err) + // Then it should return an error + if err == nil { + t.Error("Expected error when GetTemplateData fails") } - if err := os.MkdirAll(contextDir, 0755); err != nil { - t.Fatalf("Failed to create context directory: %v", err) + if !strings.Contains(err.Error(), "failed to get template data from default blueprint") { + t.Errorf("Expected error about getting template data, got: %v", err) } + }) - nestedFeature := []byte(`kind: Feature -apiVersion: blueprints.windsorcli.dev/v1alpha1 -metadata: - name: aws-eks-feature -`) - if err := os.WriteFile(filepath.Join(nestedFeaturesDir, "eks.yaml"), nestedFeature, 0644); err != nil { + t.Run("HandlesArtifactBuilderNil", func(t *testing.T) { + // Given a handler with no template root, no blueprint.yaml, and nil artifact builder + mocks := setupBlueprintMocks(t) + handler, err := NewBlueprintHandler(mocks.Runtime, nil) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + handler.artifactBuilder = nil + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + // Mock Stat to return errors (no template root, no blueprint.yaml) + handler.shims.Stat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // Set valid blueprint URL + os.Setenv("WINDSOR_BLUEPRINT_URL", "oci://registry.example.com/blueprint:latest") + defer os.Unsetenv("WINDSOR_BLUEPRINT_URL") + + // When loading blueprint + err = handler.LoadBlueprint() + + // Then it should return an error + if err == nil { + t.Error("Expected error when artifact builder is nil") + } + if !strings.Contains(err.Error(), "artifact builder not available") { + t.Errorf("Expected error about artifact builder, got: %v", err) + } + }) + + t.Run("HandlesLoadConfigWhenTemplateRootDoesNotExistButBlueprintYamlExists", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + blueprintPath := filepath.Join(tmpDir, "blueprint.yaml") + blueprintContent := `apiVersion: v1alpha1 +kind: Blueprint +metadata: + name: test-blueprint +` + if err := os.WriteFile(blueprintPath, []byte(blueprintContent), 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return nil, os.ErrNotExist + } + if path == blueprintPath { + return &mockFileInfo{name: "blueprint.yaml", isDir: false}, nil + } + return nil, os.ErrNotExist + } + + err = handler.LoadBlueprint() + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("HandlesGetTemplateDataErrorWhenNoLocalBlueprint", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + os.Setenv("WINDSOR_BLUEPRINT_URL", "oci://registry.example.com/blueprint:latest") + defer os.Unsetenv("WINDSOR_BLUEPRINT_URL") + + expectedError := fmt.Errorf("template data error") + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + return nil, expectedError + } + + err = handler.LoadBlueprint() + + if err == nil { + t.Fatal("Expected error when GetTemplateData fails") + } + if !strings.Contains(err.Error(), "failed to get template data from default blueprint") { + t.Errorf("Expected error about template data, got: %v", err) + } + }) + + t.Run("HandlesLoadDataErrorWhenNoLocalBlueprint", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + os.Setenv("WINDSOR_BLUEPRINT_URL", "oci://registry.example.com/blueprint:latest") + defer os.Unsetenv("WINDSOR_BLUEPRINT_URL") + + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + return map[string][]byte{"blueprint": []byte("invalid yaml")}, nil + } + + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("yaml unmarshal error") + } + + err = handler.LoadBlueprint() + + if err == nil { + t.Fatal("Expected error when loadData fails") + } + if !strings.Contains(err.Error(), "failed to load default blueprint data") { + t.Errorf("Expected error about loading data, got: %v", err) + } + }) + + t.Run("HandlesEmptyTemplateDataWithEmptyConfigRoot", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = "" + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return &mockFileInfo{name: "_template", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + return []os.DirEntry{}, nil + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + + err = handler.LoadBlueprint() + + if err == nil { + t.Fatal("Expected error when ConfigRoot is empty and template data is empty") + } + if !strings.Contains(err.Error(), "blueprint.yaml not found") { + t.Errorf("Expected error about blueprint not found, got: %v", err) + } + }) + + t.Run("HandlesLoadConfigErrorWhenTemplateRootDoesNotExist", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + blueprintPath := filepath.Join(tmpDir, "blueprint.yaml") + invalidBlueprintContent := `invalid: yaml: [` + if err := os.WriteFile(blueprintPath, []byte(invalidBlueprintContent), 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return nil, os.ErrNotExist + } + if path == blueprintPath { + return &mockFileInfo{name: "blueprint.yaml", isDir: false}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("yaml unmarshal error") + } + + err = handler.LoadBlueprint() + + if err == nil { + t.Fatal("Expected error when loadConfig fails") + } + if !strings.Contains(err.Error(), "failed to load blueprint config") { + t.Errorf("Expected error about loading blueprint config, got: %v", err) + } + }) + + t.Run("HandlesPullOCISourcesError", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + blueprintContent := `apiVersion: v1alpha1 +kind: Blueprint +metadata: + name: test-blueprint +sources: + - name: oci-source + url: oci://ghcr.io/test/blueprint:latest +terraformComponents: [] +kustomizations: [] +` + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return &mockFileInfo{name: "_template", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == mocks.Runtime.TemplateRoot { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.yaml", isDir: false}, + }, nil + } + return []os.DirEntry{}, nil + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + if strings.Contains(path, "blueprint.yaml") { + return []byte(blueprintContent), nil + } + return nil, os.ErrNotExist + } + + expectedError := fmt.Errorf("pull error") + mockArtifactBuilder.PullFunc = func(ociRefs []string) (map[string][]byte, error) { + return nil, expectedError + } + + err = handler.LoadBlueprint() + + if err == nil { + t.Fatal("Expected error when Pull fails") + } + if !strings.Contains(err.Error(), "failed to load OCI sources") { + t.Errorf("Expected error about loading OCI sources, got: %v", err) + } + }) + + t.Run("HandlesLoadConfigErrorAfterProcessingSources", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + blueprintContent := `apiVersion: v1alpha1 +kind: Blueprint +metadata: + name: test-blueprint +sources: [] +terraformComponents: [] +kustomizations: [] +` + + blueprintPath := filepath.Join(tmpDir, "blueprint.yaml") + invalidBlueprintContent := `invalid: yaml: [` + if err := os.WriteFile(blueprintPath, []byte(invalidBlueprintContent), 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return &mockFileInfo{name: "_template", isDir: true}, nil + } + if path == blueprintPath { + return &mockFileInfo{name: "blueprint.yaml", isDir: false}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == mocks.Runtime.TemplateRoot { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.yaml", isDir: false}, + }, nil + } + return []os.DirEntry{}, nil + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + if strings.Contains(path, "_template/blueprint.yaml") { + return []byte(blueprintContent), nil + } + if path == blueprintPath { + return []byte(invalidBlueprintContent), nil + } + return nil, os.ErrNotExist + } + + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + if strings.Contains(string(data), "invalid") { + return fmt.Errorf("yaml unmarshal error") + } + return yaml.Unmarshal(data, v) + } + + err = handler.LoadBlueprint() + + if err == nil { + t.Fatal("Expected error when loadConfig fails after processing sources") + } + if !strings.Contains(err.Error(), "failed to load blueprint config overrides") { + t.Errorf("Expected error about loading blueprint config overrides, got: %v", err) + } + }) + + t.Run("HandlesOCISourcesWithNonOCISources", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + blueprintContent := `apiVersion: v1alpha1 +kind: Blueprint +metadata: + name: test-blueprint +sources: + - name: git-source + url: git::https://example.com/repo.git + - name: oci-source + url: oci://ghcr.io/test/blueprint:latest +terraformComponents: [] +kustomizations: [] +` + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return &mockFileInfo{name: "_template", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == mocks.Runtime.TemplateRoot { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.yaml", isDir: false}, + }, nil + } + return []os.DirEntry{}, nil + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + if strings.Contains(path, "blueprint.yaml") { + return []byte(blueprintContent), nil + } + return nil, os.ErrNotExist + } + + mockArtifactBuilder.PullFunc = func(ociRefs []string) (map[string][]byte, error) { + if len(ociRefs) != 1 || ociRefs[0] != "oci://ghcr.io/test/blueprint:latest" { + t.Errorf("Expected single OCI URL, got: %v", ociRefs) + } + return nil, nil + } + + err = handler.LoadBlueprint() + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + +} + +func TestBaseBlueprintHandler_GetLocalTemplateData(t *testing.T) { + t.Run("CollectsBlueprintAndFeatureFiles", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + + mocks := setupBlueprintMocks(t) + mocks.Runtime.ProjectRoot = projectRoot + + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + + projectRoot = os.Getenv("WINDSOR_PROJECT_ROOT") + templateDir := filepath.Join(projectRoot, "contexts", "_template") + featuresDir := filepath.Join(templateDir, "features") + contextDir := filepath.Join(projectRoot, "contexts", contextName) + + if err := os.MkdirAll(featuresDir, 0755); err != nil { + t.Fatalf("Failed to create features directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + blueprintContent := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base-blueprint +`) + if err := os.WriteFile(filepath.Join(templateDir, "blueprint.yaml"), blueprintContent, 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + awsFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-feature +when: provider == "aws" +`) + if err := os.WriteFile(filepath.Join(featuresDir, "aws.yaml"), awsFeature, 0644); err != nil { + t.Fatalf("Failed to write aws feature: %v", err) + } + + observabilityFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: observability-feature +`) + if err := os.WriteFile(filepath.Join(featuresDir, "observability.yaml"), observabilityFeature, 0644); err != nil { + t.Fatalf("Failed to write observability feature: %v", err) + } + + jsonnetTemplate := []byte(`{ + terraform: { + cluster: { + node_count: 3 + } + } +}`) + if err := os.WriteFile(filepath.Join(templateDir, "terraform.jsonnet"), jsonnetTemplate, 0644); err != nil { + t.Fatalf("Failed to write jsonnet template: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if _, exists := templateData["blueprint"]; !exists { + t.Error("Expected blueprint to be collected") + } + + if _, exists := templateData["features/aws.yaml"]; !exists { + t.Error("Expected features/aws.yaml to be collected") + } + + if _, exists := templateData["features/observability.yaml"]; !exists { + t.Error("Expected features/observability.yaml to be collected") + } + + if _, exists := templateData["terraform.jsonnet"]; !exists { + t.Error("Expected terraform.jsonnet to be collected") + } + + if content, exists := templateData["blueprint"]; exists { + if !strings.Contains(string(content), "base-blueprint") { + t.Errorf("Expected blueprint content to contain 'base-blueprint', got: %s", string(content)) + } + } + + if content, exists := templateData["features/aws.yaml"]; exists { + if !strings.Contains(string(content), "aws-feature") { + t.Errorf("Expected aws feature content to contain 'aws-feature', got: %s", string(content)) + } + } + }) + + t.Run("CollectsNestedFeatures", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + + mocks := setupBlueprintMocks(t) + mocks.Runtime.ProjectRoot = projectRoot + + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + + projectRoot = os.Getenv("WINDSOR_PROJECT_ROOT") + templateDir := filepath.Join(projectRoot, "contexts", "_template") + nestedFeaturesDir := filepath.Join(templateDir, "features", "aws") + contextDir := filepath.Join(projectRoot, "contexts", contextName) + + if err := os.MkdirAll(nestedFeaturesDir, 0755); err != nil { + t.Fatalf("Failed to create nested features directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + nestedFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-eks-feature +`) + if err := os.WriteFile(filepath.Join(nestedFeaturesDir, "eks.yaml"), nestedFeature, 0644); err != nil { t.Fatalf("Failed to write nested feature: %v", err) } - templateData, err := handler.GetLocalTemplateData() + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if _, exists := templateData["features/aws/eks.yaml"]; !exists { + t.Error("Expected features/aws/eks.yaml to be collected") + } + }) + + t.Run("HandlesYamlMarshalErrorWhenComposingBlueprint", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + blueprintContent := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base-blueprint +terraform: + - path: test/component +`) + if err := os.WriteFile(filepath.Join(mocks.Runtime.TemplateRoot, "blueprint.yaml"), blueprintContent, 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + handler.shims.Stat = os.Stat + handler.shims.ReadDir = os.ReadDir + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlUnmarshal = yaml.Unmarshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextFunc = func() string { + return "test-context" + } + + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("yaml marshal error") + } + + _, err = handler.GetLocalTemplateData() + + if err == nil { + t.Fatal("Expected error when YamlMarshal fails") + } + if !strings.Contains(err.Error(), "failed to marshal composed blueprint") { + t.Errorf("Expected error about marshaling blueprint, got: %v", err) + } + }) + + t.Run("HandlesYamlMarshalErrorForSubstitutions", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + handler.shims.Stat = os.Stat + handler.shims.ReadDir = os.ReadDir + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlUnmarshal = yaml.Unmarshal + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + if _, ok := v.(map[string]any); ok { + return nil, fmt.Errorf("yaml marshal error") + } + return yaml.Marshal(v) + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "substitutions": map[string]any{ + "key": "value", + }, + }, nil + } + + _, err = handler.GetLocalTemplateData() + + if err == nil { + t.Fatal("Expected error when YamlMarshal fails for substitutions") + } + if !strings.Contains(err.Error(), "failed to marshal substitution values") { + t.Errorf("Expected error about marshaling substitutions, got: %v", err) + } + }) + + t.Run("HandlesGetContextValuesError", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + handler.shims.Stat = os.Stat + handler.shims.ReadDir = os.ReadDir + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlUnmarshal = yaml.Unmarshal + + expectedError := fmt.Errorf("get context values error") + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return nil, expectedError + } + + _, err = handler.GetLocalTemplateData() + + if err == nil { + t.Fatal("Expected error when GetContextValues fails") + } + if !strings.Contains(err.Error(), "failed to load context values") { + t.Errorf("Expected error about loading context values, got: %v", err) + } + }) + + t.Run("MergesSubstitutionValuesWithExisting", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + substitutionsContent := `common: + registry_url: registry.template.test +` + if err := os.WriteFile(filepath.Join(mocks.Runtime.TemplateRoot, "substitutions"), []byte(substitutionsContent), 0644); err != nil { + t.Fatalf("Failed to write substitutions file: %v", err) + } + + handler.shims.Stat = os.Stat + handler.shims.ReadDir = os.ReadDir + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlUnmarshal = yaml.Unmarshal + handler.shims.YamlMarshal = yaml.Marshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "substitutions": map[string]any{ + "common": map[string]any{ + "registry_url": "registry.context.test", + }, + "csi": map[string]any{ + "volume_path": "/context/volumes", + }, + }, + }, nil + } + + result, err := handler.GetLocalTemplateData() if err != nil { t.Fatalf("Expected no error, got %v", err) } - if _, exists := templateData["features/aws/eks.yaml"]; !exists { - t.Error("Expected features/aws/eks.yaml to be collected") + if substitutions, exists := result["substitutions"]; exists { + var merged map[string]any + if err := yaml.Unmarshal(substitutions, &merged); err != nil { + t.Fatalf("Failed to unmarshal merged substitutions: %v", err) + } + common, ok := merged["common"].(map[string]any) + if !ok { + t.Fatal("Expected common in merged substitutions") + } + if common["registry_url"] != "registry.context.test" { + t.Errorf("Expected context value to override template value, got: %v", common["registry_url"]) + } + if _, exists := merged["csi"]; !exists { + t.Error("Expected csi to be in merged substitutions") + } + } else { + t.Error("Expected substitutions to be in result") + } + }) + + t.Run("HandlesSubstitutionUnmarshalErrorGracefully", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + invalidSubstitutionsContent := `invalid: yaml: [` + if err := os.WriteFile(filepath.Join(mocks.Runtime.TemplateRoot, "substitutions"), []byte(invalidSubstitutionsContent), 0644); err != nil { + t.Fatalf("Failed to write substitutions file: %v", err) + } + + handler.shims.Stat = os.Stat + handler.shims.ReadDir = os.ReadDir + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlMarshal = yaml.Marshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + if strings.Contains(string(data), "invalid") { + return fmt.Errorf("yaml unmarshal error") + } + return yaml.Unmarshal(data, v) + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "substitutions": map[string]any{ + "key": "value", + }, + }, nil + } + + result, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error when unmarshal fails (should use context values only), got %v", err) + } + + if substitutions, exists := result["substitutions"]; exists { + var subs map[string]any + if err := yaml.Unmarshal(substitutions, &subs); err != nil { + t.Fatalf("Failed to unmarshal substitutions: %v", err) + } + if subs["key"] != "value" { + t.Errorf("Expected context substitution values, got: %v", subs) + } + } else { + t.Error("Expected substitutions to be in result") } }) t.Run("IgnoresNonYAMLFilesInFeatures", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2339,7 +3239,7 @@ metadata: t.Run("ComposesFeaturesByEvaluatingConditions", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2451,7 +3351,7 @@ terraform: t.Run("SetsMetadataFromContextName", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2521,7 +3421,7 @@ terraform: t.Run("HandlesSubstitutionValues", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2597,7 +3497,7 @@ terraform: t.Run("ReturnsNilWhenNoTemplateDirectory", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2623,7 +3523,7 @@ terraform: t.Run("HandlesEmptyBlueprintWithOnlyFeatures", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2688,7 +3588,7 @@ terraform: t.Run("HandlesKustomizationsInFeatures", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2768,7 +3668,7 @@ kustomize: t.Run("SkipsComposedBlueprintWhenEmpty", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2824,7 +3724,7 @@ metadata: t.Run("ValidatesCliVersionFromMetadataYaml", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2875,7 +3775,7 @@ metadata: t.Run("SkipsValidationWhenMetadataYamlDoesNotExist", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2922,7 +3822,7 @@ metadata: t.Run("ReturnsErrorWhenMetadataYamlCannotBeRead", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -2973,7 +3873,7 @@ metadata: t.Run("ReturnsErrorWhenMetadataYamlCannotBeParsed", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mocks.Runtime.ProjectRoot = projectRoot mockArtifactBuilder := artifact.NewMockArtifact() @@ -3021,12 +3921,123 @@ invalid: yaml: content t.Errorf("Expected error to contain 'failed to parse metadata.yaml', got: %v", err) } }) + + t.Run("HandlesMetadataYamlReadFileError", func(t *testing.T) { + tmpDir := t.TempDir() + mocks := setupBlueprintMocks(t) + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + + templateDir := mocks.Runtime.TemplateRoot + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + metadataPath := filepath.Join(templateDir, "metadata.yaml") + if err := os.WriteFile(metadataPath, []byte("name: test\n"), 0644); err != nil { + t.Fatalf("Failed to write metadata.yaml: %v", err) + } + + expectedError := fmt.Errorf("read file error") + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == metadataPath { + return nil, expectedError + } + return os.ReadFile(name) + } + + _, err = handler.GetLocalTemplateData() + + if err == nil { + t.Fatal("Expected error when metadata.yaml cannot be read") + } + if !strings.Contains(err.Error(), "failed to read metadata.yaml") { + t.Errorf("Expected error to contain 'failed to read metadata.yaml', got: %v", err) + } + }) + + t.Run("HandlesLoadSchemaFromBytesError", func(t *testing.T) { + tmpDir := t.TempDir() + mocks := setupBlueprintMocks(t) + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + + templateDir := mocks.Runtime.TemplateRoot + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + schemaPath := filepath.Join(templateDir, "schema.yaml") + if err := os.WriteFile(schemaPath, []byte("invalid schema"), 0644); err != nil { + t.Fatalf("Failed to write schema: %v", err) + } + + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + expectedError := fmt.Errorf("schema load error") + mockConfigHandler.LoadSchemaFromBytesFunc = func(data []byte) error { + return expectedError + } + + _, err = handler.GetLocalTemplateData() + + if err == nil { + t.Fatal("Expected error when schema cannot be loaded") + } + if !strings.Contains(err.Error(), "failed to load schema") { + t.Errorf("Expected error to contain 'failed to load schema', got: %v", err) + } + }) + + t.Run("HandlesGetContextValuesError", func(t *testing.T) { + tmpDir := t.TempDir() + mocks := setupBlueprintMocks(t) + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + + templateDir := mocks.Runtime.TemplateRoot + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + expectedError := fmt.Errorf("context values error") + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return nil, expectedError + } + + _, err = handler.GetLocalTemplateData() + + if err == nil { + t.Fatal("Expected error when context values cannot be loaded") + } + if !strings.Contains(err.Error(), "failed to load context values") { + t.Errorf("Expected error to contain 'failed to load context values', got: %v", err) + } + }) } func TestBaseBlueprintHandler_Generate(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { t.Helper() - mocks := setupMocks(t) + mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact() handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) if err != nil { diff --git a/pkg/composer/blueprint/feature_evaluator_test.go b/pkg/composer/blueprint/feature_evaluator_test.go index 913139dcc..791086b86 100644 --- a/pkg/composer/blueprint/feature_evaluator_test.go +++ b/pkg/composer/blueprint/feature_evaluator_test.go @@ -6,26 +6,37 @@ import ( "strings" "testing" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/runtime" "github.com/windsorcli/cli/pkg/runtime/config" "github.com/windsorcli/cli/pkg/runtime/shell" ) +// ============================================================================= +// Test Setup +// ============================================================================= + +func setupFeatureEvaluator(t *testing.T) *FeatureEvaluator { + t.Helper() + tmpDir := t.TempDir() + configHandler := config.NewMockConfigHandler() + mockShell := shell.NewMockShell() + rt := &runtime.Runtime{ + ProjectRoot: tmpDir, + ConfigRoot: tmpDir, + ConfigHandler: configHandler, + Shell: mockShell, + } + return NewFeatureEvaluator(rt) +} + // ============================================================================= // Test Constructor // ============================================================================= func TestNewFeatureEvaluator(t *testing.T) { t.Run("CreatesNewFeatureEvaluatorSuccessfully", func(t *testing.T) { - configHandler := config.NewMockConfigHandler() - mockShell := shell.NewMockShell() - rt := &runtime.Runtime{ - ProjectRoot: "/test", - ConfigRoot: "/test", - ConfigHandler: configHandler, - Shell: mockShell, - } - evaluator := NewFeatureEvaluator(rt) + evaluator := setupFeatureEvaluator(t) if evaluator == nil { t.Fatal("Expected evaluator, got nil") } @@ -37,15 +48,7 @@ func TestNewFeatureEvaluator(t *testing.T) { // ============================================================================= func TestFeatureEvaluator_EvaluateExpression(t *testing.T) { - configHandler := config.NewMockConfigHandler() - mockShell := shell.NewMockShell() - rt := &runtime.Runtime{ - ProjectRoot: "/test", - ConfigRoot: "/test", - ConfigHandler: configHandler, - Shell: mockShell, - } - evaluator := NewFeatureEvaluator(rt) + evaluator := setupFeatureEvaluator(t) tests := []struct { name string @@ -186,15 +189,7 @@ func TestFeatureEvaluator_EvaluateExpression(t *testing.T) { } func TestFeatureEvaluator_EvaluateValue(t *testing.T) { - configHandler := config.NewMockConfigHandler() - mockShell := shell.NewMockShell() - rt := &runtime.Runtime{ - ProjectRoot: "/test", - ConfigRoot: "/test", - ConfigHandler: configHandler, - Shell: mockShell, - } - evaluator := NewFeatureEvaluator(rt) + evaluator := setupFeatureEvaluator(t) tests := []struct { name string @@ -310,13 +305,7 @@ func TestFeatureEvaluator_EvaluateValue(t *testing.T) { } func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { - configHandler := config.NewMockConfigHandler() - mockShell := shell.NewMockShell() - rt := &runtime.Runtime{ - ConfigHandler: configHandler, - Shell: mockShell, - } - evaluator := NewFeatureEvaluator(rt) + evaluator := setupFeatureEvaluator(t) t.Run("EvaluatesLiteralValues", func(t *testing.T) { defaults := map[string]any{ @@ -658,13 +647,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { // ============================================================================= func TestFeatureEvaluator_extractExpression(t *testing.T) { - configHandler := config.NewMockConfigHandler() - mockShell := shell.NewMockShell() - rt := &runtime.Runtime{ - ConfigHandler: configHandler, - Shell: mockShell, - } - evaluator := NewFeatureEvaluator(rt) + evaluator := setupFeatureEvaluator(t) tests := []struct { name string @@ -724,13 +707,7 @@ func TestFeatureEvaluator_extractExpression(t *testing.T) { } func TestFeatureEvaluator_evaluateDefaultValue(t *testing.T) { - configHandler := config.NewMockConfigHandler() - mockShell := shell.NewMockShell() - rt := &runtime.Runtime{ - ConfigHandler: configHandler, - Shell: mockShell, - } - evaluator := NewFeatureEvaluator(rt) + evaluator := setupFeatureEvaluator(t) t.Run("LiteralStringPassesThrough", func(t *testing.T) { result, err := evaluator.evaluateDefaultValue("talos", map[string]any{}, "features/test.yaml") @@ -1718,3 +1695,422 @@ func writeTestFile(path, content string) error { _, err = file.WriteString(content) return err } + +func TestFeatureEvaluator_ProcessFeature(t *testing.T) { + evaluator := setupFeatureEvaluator(t) + + t.Run("ReturnsNilWhenWhenConditionIsFalse", func(t *testing.T) { + feature := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "test-feature"}, + When: "provider == 'aws'", + } + config := map[string]any{"provider": "gcp"} + + result, err := evaluator.ProcessFeature(feature, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result != nil { + t.Error("Expected nil when condition is false") + } + }) + + t.Run("ReturnsErrorWhenWhenConditionEvaluationFails", func(t *testing.T) { + feature := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "test-feature"}, + When: "invalid expression", + } + config := map[string]any{} + + _, err := evaluator.ProcessFeature(feature, config) + + if err == nil { + t.Fatal("Expected error when condition evaluation fails") + } + }) + + t.Run("ProcessesFeatureWithoutWhenCondition", func(t *testing.T) { + feature := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "test-feature"}, + } + config := map[string]any{} + + result, err := evaluator.ProcessFeature(feature, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result == nil { + t.Fatal("Expected processed feature, got nil") + } + if result.Metadata.Name != "test-feature" { + t.Errorf("Expected feature name 'test-feature', got '%s'", result.Metadata.Name) + } + }) + + t.Run("FiltersTerraformComponentsByWhenCondition", func(t *testing.T) { + feature := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "test-feature"}, + TerraformComponents: []blueprintv1alpha1.ConditionalTerraformComponent{ + { + TerraformComponent: blueprintv1alpha1.TerraformComponent{Path: "component1"}, + When: "provider == 'aws'", + }, + { + TerraformComponent: blueprintv1alpha1.TerraformComponent{Path: "component2"}, + When: "provider == 'gcp'", + }, + }, + } + config := map[string]any{"provider": "aws"} + + result, err := evaluator.ProcessFeature(feature, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(result.TerraformComponents) != 1 { + t.Errorf("Expected 1 component, got %d", len(result.TerraformComponents)) + } + if result.TerraformComponents[0].TerraformComponent.Path != "component1" { + t.Errorf("Expected component1, got %s", result.TerraformComponents[0].TerraformComponent.Path) + } + }) + + t.Run("FiltersKustomizationsByWhenCondition", func(t *testing.T) { + feature := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "test-feature"}, + Kustomizations: []blueprintv1alpha1.ConditionalKustomization{ + { + Kustomization: blueprintv1alpha1.Kustomization{Name: "kustomization1"}, + When: "provider == 'aws'", + }, + { + Kustomization: blueprintv1alpha1.Kustomization{Name: "kustomization2"}, + When: "provider == 'gcp'", + }, + }, + } + config := map[string]any{"provider": "aws"} + + result, err := evaluator.ProcessFeature(feature, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(result.Kustomizations) != 1 { + t.Errorf("Expected 1 kustomization, got %d", len(result.Kustomizations)) + } + if result.Kustomizations[0].Kustomization.Name != "kustomization1" { + t.Errorf("Expected kustomization1, got %s", result.Kustomizations[0].Kustomization.Name) + } + }) + + t.Run("HandlesEvaluateDefaultsErrorForTerraformComponent", func(t *testing.T) { + feature := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "test-feature"}, + TerraformComponents: []blueprintv1alpha1.ConditionalTerraformComponent{ + { + TerraformComponent: blueprintv1alpha1.TerraformComponent{ + Path: "component1", + Inputs: map[string]any{ + "key": "${invalid expression [[[", + }, + }, + }, + }, + } + config := map[string]any{} + + _, err := evaluator.ProcessFeature(feature, config) + + if err == nil { + t.Fatal("Expected error when EvaluateDefaults fails") + } + if !strings.Contains(err.Error(), "failed to evaluate inputs") { + t.Errorf("Expected error about evaluating inputs, got: %v", err) + } + }) + + t.Run("HandlesEvaluateSubstitutionsErrorForKustomization", func(t *testing.T) { + feature := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "test-feature"}, + Kustomizations: []blueprintv1alpha1.ConditionalKustomization{ + { + Kustomization: blueprintv1alpha1.Kustomization{ + Name: "kustomization1", + Substitutions: map[string]string{ + "key": "${invalid expression [[[", + }, + }, + }, + }, + } + config := map[string]any{} + + _, err := evaluator.ProcessFeature(feature, config) + + if err == nil { + t.Fatal("Expected error when evaluateSubstitutions fails") + } + if !strings.Contains(err.Error(), "failed to evaluate substitutions") { + t.Errorf("Expected error about evaluating substitutions, got: %v", err) + } + }) +} + +func TestFeatureEvaluator_MergeFeatures(t *testing.T) { + evaluator := setupFeatureEvaluator(t) + + t.Run("ReturnsNilWhenFeaturesIsEmpty", func(t *testing.T) { + result := evaluator.MergeFeatures([]*blueprintv1alpha1.Feature{}) + + if result != nil { + t.Error("Expected nil when features is empty") + } + }) + + t.Run("MergesMultipleFeatures", func(t *testing.T) { + feature1 := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "feature1"}, + TerraformComponents: []blueprintv1alpha1.ConditionalTerraformComponent{ + {TerraformComponent: blueprintv1alpha1.TerraformComponent{Path: "component1"}}, + }, + } + feature2 := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "feature2"}, + Kustomizations: []blueprintv1alpha1.ConditionalKustomization{ + {Kustomization: blueprintv1alpha1.Kustomization{Name: "kustomization1"}}, + }, + } + + result := evaluator.MergeFeatures([]*blueprintv1alpha1.Feature{feature1, feature2}) + + if result == nil { + t.Fatal("Expected merged feature, got nil") + } + if result.Metadata.Name != "merged-features" { + t.Errorf("Expected name 'merged-features', got '%s'", result.Metadata.Name) + } + if len(result.TerraformComponents) != 1 { + t.Errorf("Expected 1 terraform component, got %d", len(result.TerraformComponents)) + } + if len(result.Kustomizations) != 1 { + t.Errorf("Expected 1 kustomization, got %d", len(result.Kustomizations)) + } + }) +} + +func TestFeatureEvaluator_FeatureToBlueprint(t *testing.T) { + evaluator := setupFeatureEvaluator(t) + + t.Run("ReturnsNilWhenFeatureIsNil", func(t *testing.T) { + result := evaluator.FeatureToBlueprint(nil) + + if result != nil { + t.Error("Expected nil when feature is nil") + } + }) + + t.Run("ConvertsFeatureToBlueprint", func(t *testing.T) { + feature := &blueprintv1alpha1.Feature{ + Metadata: blueprintv1alpha1.Metadata{Name: "test-feature"}, + TerraformComponents: []blueprintv1alpha1.ConditionalTerraformComponent{ + {TerraformComponent: blueprintv1alpha1.TerraformComponent{Path: "component1"}}, + }, + Kustomizations: []blueprintv1alpha1.ConditionalKustomization{ + {Kustomization: blueprintv1alpha1.Kustomization{Name: "kustomization1"}}, + }, + } + + result := evaluator.FeatureToBlueprint(feature) + + if result == nil { + t.Fatal("Expected blueprint, got nil") + } + if result.Kind != "Blueprint" { + t.Errorf("Expected kind 'Blueprint', got '%s'", result.Kind) + } + if result.ApiVersion != "v1alpha1" { + t.Errorf("Expected apiVersion 'v1alpha1', got '%s'", result.ApiVersion) + } + if result.Metadata.Name != "test-feature" { + t.Errorf("Expected name 'test-feature', got '%s'", result.Metadata.Name) + } + if len(result.TerraformComponents) != 1 { + t.Errorf("Expected 1 terraform component, got %d", len(result.TerraformComponents)) + } + if len(result.Kustomizations) != 1 { + t.Errorf("Expected 1 kustomization, got %d", len(result.Kustomizations)) + } + }) +} + +func TestFeatureEvaluator_evaluateSubstitutions(t *testing.T) { + evaluator := setupFeatureEvaluator(t) + + t.Run("HandlesSubstitutionsWithoutExpressions", func(t *testing.T) { + substitutions := map[string]string{ + "key1": "value1", + "key2": "value2", + } + config := map[string]any{} + + result, err := evaluator.evaluateSubstitutions(substitutions, config, "") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result["key1"] != "value1" { + t.Errorf("Expected 'value1', got '%s'", result["key1"]) + } + if result["key2"] != "value2" { + t.Errorf("Expected 'value2', got '%s'", result["key2"]) + } + }) + + t.Run("HandlesSubstitutionsWithExpressions", func(t *testing.T) { + substitutions := map[string]string{ + "key1": "${provider}", + } + config := map[string]any{"provider": "aws"} + + result, err := evaluator.evaluateSubstitutions(substitutions, config, "") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result["key1"] != "aws" { + t.Errorf("Expected 'aws', got '%s'", result["key1"]) + } + }) + + t.Run("HandlesNilEvaluatedValue", func(t *testing.T) { + substitutions := map[string]string{ + "key1": "${nonexistent}", + } + config := map[string]any{} + + result, err := evaluator.evaluateSubstitutions(substitutions, config, "") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result["key1"] != "" { + t.Errorf("Expected empty string for nil value, got '%s'", result["key1"]) + } + }) + + t.Run("ReturnsErrorWhenEvaluationFails", func(t *testing.T) { + substitutions := map[string]string{ + "key1": "${invalid expression", + } + config := map[string]any{} + + _, err := evaluator.evaluateSubstitutions(substitutions, config, "") + + if err == nil { + t.Fatal("Expected error when evaluation fails") + } + }) +} + +func TestFeatureEvaluator_buildExprEnvironment(t *testing.T) { + evaluator := setupFeatureEvaluator(t) + + t.Run("HandlesJsonnetFunctionWithWrongNumberOfArguments", func(t *testing.T) { + _, err := evaluator.EvaluateValue("jsonnet()", map[string]any{}, "") + + if err == nil { + t.Fatal("Expected error when jsonnet() called with no arguments") + } + if !strings.Contains(err.Error(), "not enough arguments") { + t.Errorf("Expected error about wrong number of arguments, got: %v", err) + } + }) + + t.Run("HandlesJsonnetFunctionWithWrongType", func(t *testing.T) { + _, err := evaluator.EvaluateValue("jsonnet(123)", map[string]any{}, "") + + if err == nil { + t.Fatal("Expected error when jsonnet() called with non-string") + } + if !strings.Contains(err.Error(), "cannot use int") { + t.Errorf("Expected error about wrong type, got: %v", err) + } + }) + + t.Run("HandlesFileFunctionWithWrongNumberOfArguments", func(t *testing.T) { + _, err := evaluator.EvaluateValue("file()", map[string]any{}, "") + + if err == nil { + t.Fatal("Expected error when file() called with no arguments") + } + if !strings.Contains(err.Error(), "not enough arguments") { + t.Errorf("Expected error about wrong number of arguments, got: %v", err) + } + }) + + t.Run("HandlesFileFunctionWithWrongType", func(t *testing.T) { + _, err := evaluator.EvaluateValue("file(123)", map[string]any{}, "") + + if err == nil { + t.Fatal("Expected error when file() called with non-string") + } + if !strings.Contains(err.Error(), "cannot use int") { + t.Errorf("Expected error about wrong type, got: %v", err) + } + }) + + t.Run("HandlesSplitFunctionWithWrongNumberOfArguments", func(t *testing.T) { + _, err := evaluator.EvaluateValue("split('a')", map[string]any{}, "") + + if err == nil { + t.Fatal("Expected error when split() called with 1 argument") + } + if !strings.Contains(err.Error(), "not enough arguments") { + t.Errorf("Expected error about wrong number of arguments, got: %v", err) + } + }) + + t.Run("HandlesSplitFunctionWithWrongFirstArgumentType", func(t *testing.T) { + _, err := evaluator.EvaluateValue("split(123, ',')", map[string]any{}, "") + + if err == nil { + t.Fatal("Expected error when split() called with non-string first argument") + } + if !strings.Contains(err.Error(), "cannot use int") { + t.Errorf("Expected error about wrong type, got: %v", err) + } + }) + + t.Run("HandlesSplitFunctionWithWrongSecondArgumentType", func(t *testing.T) { + _, err := evaluator.EvaluateValue("split('a,b', 123)", map[string]any{}, "") + + if err == nil { + t.Fatal("Expected error when split() called with non-string second argument") + } + if !strings.Contains(err.Error(), "cannot use int") { + t.Errorf("Expected error about wrong type, got: %v", err) + } + }) + + t.Run("HandlesSplitFunctionSuccessfully", func(t *testing.T) { + result, err := evaluator.EvaluateValue("split('a,b,c', ',')", map[string]any{}, "") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + resultSlice, ok := result.([]any) + if !ok { + t.Fatalf("Expected []any, got %T", result) + } + if len(resultSlice) != 3 { + t.Errorf("Expected 3 elements, got %d", len(resultSlice)) + } + if resultSlice[0] != "a" || resultSlice[1] != "b" || resultSlice[2] != "c" { + t.Errorf("Expected ['a', 'b', 'c'], got %v", resultSlice) + } + }) +} From b1f2c09dfaf2a4e943be8230e8fe4c9df0a6ffa1 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Sat, 15 Nov 2025 13:54:12 -0500 Subject: [PATCH 6/8] Fix for Windows Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- .../oci_module_resolver_private_test.go | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/pkg/composer/terraform/oci_module_resolver_private_test.go b/pkg/composer/terraform/oci_module_resolver_private_test.go index 3679e0afb..5269c69be 100644 --- a/pkg/composer/terraform/oci_module_resolver_private_test.go +++ b/pkg/composer/terraform/oci_module_resolver_private_test.go @@ -5,6 +5,8 @@ import ( "errors" "io" "os" + "path/filepath" + "runtime" "strings" "testing" @@ -969,10 +971,22 @@ func TestOCIModuleResolver_validateAndSanitizePath(t *testing.T) { resolver := setup(t) // When validating absolute paths - testCases := []string{ - "/etc/passwd", - "/root/file.tf", - "/tmp/module/main.tf", + // Use platform-specific absolute paths + var testCases []string + if runtime.GOOS == "windows" { + // On Windows, use Windows-style absolute paths with drive letters + testCases = []string{ + filepath.Join("C:", "Windows", "System32", "config", "sam"), + filepath.Join("C:", "Users", "file.tf"), + filepath.Join("C:", string(filepath.Separator), "tmp", "module", "main.tf"), + } + } else { + // On Unix-like systems, use Unix-style absolute paths + testCases = []string{ + filepath.Join(string(filepath.Separator), "etc", "passwd"), + filepath.Join(string(filepath.Separator), "root", "file.tf"), + filepath.Join(string(filepath.Separator), "tmp", "module", "main.tf"), + } } for _, path := range testCases { @@ -980,6 +994,7 @@ func TestOCIModuleResolver_validateAndSanitizePath(t *testing.T) { _, err := resolver.validateAndSanitizePath(path) if err == nil { t.Errorf("Expected error for absolute path %s, got nil", path) + continue } if !strings.Contains(err.Error(), "absolute paths are not allowed") { t.Errorf("Expected absolute path error for %s, got: %v", path, err) From 078812490831ff55dd49f54686e401bf0d376520 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Sat, 15 Nov 2025 14:04:39 -0500 Subject: [PATCH 7/8] Windows path fix Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- pkg/composer/terraform/oci_module_resolver.go | 4 ++- .../oci_module_resolver_private_test.go | 29 +++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/pkg/composer/terraform/oci_module_resolver.go b/pkg/composer/terraform/oci_module_resolver.go index 0cfd5e567..cb2890385 100644 --- a/pkg/composer/terraform/oci_module_resolver.go +++ b/pkg/composer/terraform/oci_module_resolver.go @@ -284,12 +284,14 @@ func (h *OCIModuleResolver) extractModuleFromArtifact(artifactData []byte, modul // validateAndSanitizePath sanitizes a file path for safe extraction by removing path traversal sequences // and rejecting absolute paths. Returns the cleaned path if valid, or an error if the path is unsafe. +// This function checks for absolute paths in a platform-agnostic way since tar archives use Unix-style paths +// regardless of the host OS. func (h *OCIModuleResolver) validateAndSanitizePath(path string) (string, error) { cleanPath := filepath.Clean(path) if strings.Contains(cleanPath, "..") { return "", fmt.Errorf("path contains directory traversal sequence: %s", path) } - if filepath.IsAbs(cleanPath) { + if strings.HasPrefix(cleanPath, string(filepath.Separator)) || (len(cleanPath) >= 2 && cleanPath[1] == ':' && (cleanPath[0] >= 'A' && cleanPath[0] <= 'Z' || cleanPath[0] >= 'a' && cleanPath[0] <= 'z')) { return "", fmt.Errorf("absolute paths are not allowed: %s", path) } return cleanPath, nil diff --git a/pkg/composer/terraform/oci_module_resolver_private_test.go b/pkg/composer/terraform/oci_module_resolver_private_test.go index 5269c69be..f5cd8718b 100644 --- a/pkg/composer/terraform/oci_module_resolver_private_test.go +++ b/pkg/composer/terraform/oci_module_resolver_private_test.go @@ -971,22 +971,21 @@ func TestOCIModuleResolver_validateAndSanitizePath(t *testing.T) { resolver := setup(t) // When validating absolute paths - // Use platform-specific absolute paths - var testCases []string + // Tar archives use Unix-style paths (forward slashes) regardless of OS, + // so test with both Unix-style and Windows-style absolute paths + testCases := []string{ + // Unix-style absolute paths (what would come from tar archives) + "/etc/passwd", + "/root/file.tf", + "/tmp/module/main.tf", + } + + // Also test Windows-style absolute paths if runtime.GOOS == "windows" { - // On Windows, use Windows-style absolute paths with drive letters - testCases = []string{ - filepath.Join("C:", "Windows", "System32", "config", "sam"), - filepath.Join("C:", "Users", "file.tf"), - filepath.Join("C:", string(filepath.Separator), "tmp", "module", "main.tf"), - } - } else { - // On Unix-like systems, use Unix-style absolute paths - testCases = []string{ - filepath.Join(string(filepath.Separator), "etc", "passwd"), - filepath.Join(string(filepath.Separator), "root", "file.tf"), - filepath.Join(string(filepath.Separator), "tmp", "module", "main.tf"), - } + testCases = append(testCases, + filepath.Join("C:", string(filepath.Separator), "Windows", "System32", "config", "sam"), + filepath.Join("C:", string(filepath.Separator), "Users", "file.tf"), + ) } for _, path := range testCases { From 3591185f87d35fe3620db429467131acc483fd46 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Sat, 15 Nov 2025 14:09:20 -0500 Subject: [PATCH 8/8] Fix windows tests Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- pkg/composer/blueprint/blueprint_handler_public_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/composer/blueprint/blueprint_handler_public_test.go b/pkg/composer/blueprint/blueprint_handler_public_test.go index 0762f6e13..dcf6ad2e0 100644 --- a/pkg/composer/blueprint/blueprint_handler_public_test.go +++ b/pkg/composer/blueprint/blueprint_handler_public_test.go @@ -2643,7 +2643,8 @@ kustomizations: [] } handler.shims.ReadFile = func(path string) ([]byte, error) { - if strings.Contains(path, "_template/blueprint.yaml") { + normalizedPath := filepath.ToSlash(path) + if strings.Contains(normalizedPath, "_template/blueprint.yaml") || strings.HasSuffix(normalizedPath, "_template/blueprint.yaml") { return []byte(blueprintContent), nil } if path == blueprintPath {