From 41fcc0fb8d369025d7735661a13f4a1475cc88b7 Mon Sep 17 00:00:00 2001 From: dkooll Date: Sat, 27 Dec 2025 16:02:24 +0100 Subject: [PATCH 1/2] feat: add tests --- .github/dependabot.yml | 15 + .github/workflows/tests.yml | 36 ++ block_data_test.go | 245 ++++++++++++++ config.go | 14 + config_test.go | 136 ++++++++ diffy.go | 11 +- diffy_validate_test.go | 176 ++++++++++ example_usage_test.go | 77 +++++ go.mod | 1 + issue_test.go | 196 +++++++++++ logger_test.go | 144 ++++++++ middleware_test.go | 145 ++++++++ parser_test.go | 390 ++++++++++++++++++++++ repo_test.go | 136 ++++++++ runner_test.go | 195 +++++++++++ types_test.go | 162 +++++++++ validator_test.go | 636 ++++++++++++++++++++++++++++++++++++ 17 files changed, 2713 insertions(+), 2 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/tests.yml create mode 100644 block_data_test.go create mode 100644 config_test.go create mode 100644 diffy_validate_test.go create mode 100644 example_usage_test.go create mode 100644 issue_test.go create mode 100644 logger_test.go create mode 100644 middleware_test.go create mode 100644 parser_test.go create mode 100644 repo_test.go create mode 100644 runner_test.go create mode 100644 types_test.go create mode 100644 validator_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6c6b75c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "feat(deps):" + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "feat(deps):" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..22914cc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,36 @@ +name: tests + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Run tests (no cache, verbose) + env: + GOCACHE: ${{ runner.temp }}/go-cache + run: | + go clean -testcache + go test -count=1 -v -coverprofile=coverage.out ./... + + - name: Display coverage summary + run: go tool cover -func=coverage.out + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage.out + path: coverage.out diff --git a/block_data_test.go b/block_data_test.go new file mode 100644 index 0000000..e11ad28 --- /dev/null +++ b/block_data_test.go @@ -0,0 +1,245 @@ +package diffy + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func TestNewBlockDataInitializesCollections(t *testing.T) { + bd := NewBlockData() + + if bd.Properties == nil { + t.Fatalf("Properties map should be initialized") + } + if bd.StaticBlocks == nil { + t.Fatalf("StaticBlocks map should be initialized") + } + if bd.DynamicBlocks == nil { + t.Fatalf("DynamicBlocks map should be initialized") + } + if bd.IgnoreChanges == nil { + t.Fatalf("IgnoreChanges slice should be initialized") + } +} + +func TestParseAttributesAndBlocks(t *testing.T) { + body := parseHCLBody(t, ` +name = "vnet" +address_space = ["10.0.0.0/16"] + +lifecycle { + ignore_changes = [tags, "location"] +} + +dynamic "subnet" { + for_each = var.subnets + content { + name = each.key + address_prefix = each.value + } +} + +subnet { + name = "subnet1" + address_prefix = "10.0.1.0/24" +} +`) + + bd := NewBlockData() + bd.ParseAttributes(body) + bd.ParseBlocks(body) + + if diff := cmp.Diff(map[string]bool{ + "name": true, + "address_space": true, + "subnet": true, + }, bd.Properties); diff != "" { + t.Fatalf("Properties mismatch (-want +got):\n%s", diff) + } + + if len(bd.StaticBlocks["subnet"]) != 1 { + t.Fatalf("Expected 1 static subnet block, got %d", len(bd.StaticBlocks["subnet"])) + } + + staticSubnet := bd.StaticBlocks["subnet"][0] + if diff := cmp.Diff(map[string]bool{ + "name": true, + "address_prefix": true, + }, staticSubnet.Data.Properties); diff != "" { + t.Fatalf("Static subnet properties mismatch (-want +got):\n%s", diff) + } + + dynamicSubnet := bd.DynamicBlocks["subnet"] + if dynamicSubnet == nil { + t.Fatalf("Dynamic subnet block should be parsed") + } + + if diff := cmp.Diff(map[string]bool{ + "name": true, + "address_prefix": true, + }, dynamicSubnet.Data.Properties); diff != "" { + t.Fatalf("Dynamic subnet properties mismatch (-want +got):\n%s", diff) + } + + if diff := cmp.Diff([]string{"tags"}, bd.IgnoreChanges, cmpopts.SortSlices(func(a, b string) bool { + return a < b + })); diff != "" { + t.Fatalf("IgnoreChanges mismatch (-want +got):\n%s", diff) + } +} + +func TestBlockDataValidateReportsMissingParts(t *testing.T) { + schema := &SchemaBlock{ + Attributes: map[string]*SchemaAttribute{ + "name": {Required: true}, + "location": {Required: true}, + "tags": {Optional: true}, + }, + BlockTypes: map[string]*SchemaBlockType{ + "subnet": { + MinItems: 1, + Block: &SchemaBlock{ + Attributes: map[string]*SchemaAttribute{ + "name": {Required: true}, + }, + BlockTypes: map[string]*SchemaBlockType{}, + }, + }, + }, + } + + bd := BlockData{ + Properties: map[string]bool{"location": true}, // missing "name" + StaticBlocks: map[string][]*ParsedBlock{}, + DynamicBlocks: map[string]*ParsedBlock{}, + IgnoreChanges: []string{"tags"}, + } + + var findings []ValidationFinding + bd.Validate("azurerm_virtual_network", "azurerm_virtual_network.test", schema, nil, &findings) + + want := []ValidationFinding{ + { + ResourceType: "azurerm_virtual_network", + Path: "azurerm_virtual_network.test", + Name: "name", + Required: true, + IsBlock: false, + }, + { + ResourceType: "azurerm_virtual_network", + Path: "azurerm_virtual_network.test", + Name: "subnet", + Required: true, + IsBlock: true, + }, + } + + lessFinding := func(a, b ValidationFinding) bool { + if a.Path == b.Path { + return a.Name < b.Name + } + return a.Path < b.Path + } + + if diff := cmp.Diff(want, findings, cmpopts.SortSlices(lessFinding)); diff != "" { + t.Fatalf("Unexpected validation findings (-want +got):\n%s", diff) + } +} + +func TestMergeBlocksCombinesData(t *testing.T) { + dest := &ParsedBlock{ + Data: BlockData{ + Properties: map[string]bool{"name": true}, + StaticBlocks: map[string][]*ParsedBlock{ + "tags": {{ + Data: BlockData{ + Properties: map[string]bool{"environment": true}, + StaticBlocks: map[string][]*ParsedBlock{ + "metadata": {}, + }, + DynamicBlocks: map[string]*ParsedBlock{}, + IgnoreChanges: []string{}, + }, + }}, + }, + DynamicBlocks: map[string]*ParsedBlock{ + "rule": { + Data: BlockData{ + Properties: map[string]bool{"priority": true}, + StaticBlocks: map[string][]*ParsedBlock{}, + DynamicBlocks: map[string]*ParsedBlock{}, + IgnoreChanges: []string{}, + }, + }, + }, + IgnoreChanges: []string{"name"}, + }, + } + + src := &ParsedBlock{ + Data: BlockData{ + Properties: map[string]bool{"location": true}, + StaticBlocks: map[string][]*ParsedBlock{ + "tags": {{ + Data: BlockData{ + Properties: map[string]bool{"costcenter": true}, + StaticBlocks: map[string][]*ParsedBlock{}, + DynamicBlocks: map[string]*ParsedBlock{}, + IgnoreChanges: []string{}, + }, + }}, + }, + DynamicBlocks: map[string]*ParsedBlock{ + "rule": { + Data: BlockData{ + Properties: map[string]bool{"action": true}, + StaticBlocks: map[string][]*ParsedBlock{}, + DynamicBlocks: map[string]*ParsedBlock{}, + IgnoreChanges: []string{}, + }, + }, + }, + IgnoreChanges: []string{"tags"}, + }, + } + + mergeBlocks(dest, src) + + if diff := cmp.Diff(map[string]bool{"name": true, "location": true}, dest.Data.Properties); diff != "" { + t.Fatalf("Merged properties mismatch (-want +got):\n%s", diff) + } + + if len(dest.Data.StaticBlocks["tags"]) != 2 { + t.Fatalf("Expected 2 tag blocks after merge, got %d", len(dest.Data.StaticBlocks["tags"])) + } + + if diff := cmp.Diff(map[string]bool{"priority": true, "action": true}, dest.Data.DynamicBlocks["rule"].Data.Properties); diff != "" { + t.Fatalf("Merged dynamic block properties mismatch (-want +got):\n%s", diff) + } + + if diff := cmp.Diff([]string{"name", "tags"}, dest.Data.IgnoreChanges, cmpopts.SortSlices(func(a, b string) bool { + return a < b + })); diff != "" { + t.Fatalf("IgnoreChanges not merged as expected (-want +got):\n%s", diff) + } +} + +func parseHCLBody(t *testing.T, src string) *hclsyntax.Body { + t.Helper() + + file, diags := hclsyntax.ParseConfig([]byte(src), "test.hcl", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("failed to parse HCL: %v", diags) + } + + body, ok := file.Body.(*hclsyntax.Body) + if !ok { + t.Fatalf("unexpected body type %T", file.Body) + } + return body +} diff --git a/config.go b/config.go index 219fe3c..211387c 100644 --- a/config.go +++ b/config.go @@ -15,6 +15,8 @@ type SchemaValidatorOptions struct { Silent bool ExcludedResources []string ExcludedDataSources []string + Parser HCLParser + TerraformRunner TerraformRunner } type SchemaValidatorOption func(*SchemaValidatorOptions) @@ -43,3 +45,15 @@ func WithExcludedDataSources(dataSources ...string) SchemaValidatorOption { opts.ExcludedDataSources = append(opts.ExcludedDataSources, dataSources...) } } + +func WithParser(parser HCLParser) SchemaValidatorOption { + return func(opts *SchemaValidatorOptions) { + opts.Parser = parser + } +} + +func WithTerraformRunner(runner TerraformRunner) SchemaValidatorOption { + return func(opts *SchemaValidatorOptions) { + opts.TerraformRunner = runner + } +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..b52d97f --- /dev/null +++ b/config_test.go @@ -0,0 +1,136 @@ +package diffy + +import ( + "testing" +) + +func TestWithTerraformRoot(t *testing.T) { + opts := &SchemaValidatorOptions{} + path := "/test/path" + + WithTerraformRoot(path)(opts) + + if opts.TerraformRoot != path { + t.Errorf("WithTerraformRoot() = %s, want %s", opts.TerraformRoot, path) + } +} + +func TestWithGitHubIssueCreation(t *testing.T) { + opts := &SchemaValidatorOptions{} + + WithGitHubIssueCreation()(opts) + + if !opts.CreateGitHubIssue { + t.Error("WithGitHubIssueCreation() should set CreateGitHubIssue to true") + } +} + +func TestWithExcludedResources(t *testing.T) { + tests := []struct { + name string + resources []string + wantLen int + }{ + { + name: "single resource", + resources: []string{"azurerm_resource_group"}, + wantLen: 1, + }, + { + name: "multiple resources", + resources: []string{"azurerm_resource_group", "azurerm_virtual_network"}, + wantLen: 2, + }, + { + name: "empty", + resources: []string{}, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &SchemaValidatorOptions{} + + WithExcludedResources(tt.resources...)(opts) + + if len(opts.ExcludedResources) != tt.wantLen { + t.Errorf("ExcludedResources length = %d, want %d", len(opts.ExcludedResources), tt.wantLen) + } + }) + } +} + +func TestWithExcludedDataSources(t *testing.T) { + tests := []struct { + name string + dataSources []string + wantLen int + }{ + { + name: "single data source", + dataSources: []string{"azurerm_resource_group"}, + wantLen: 1, + }, + { + name: "multiple data sources", + dataSources: []string{"azurerm_resource_group", "azurerm_virtual_network"}, + wantLen: 2, + }, + { + name: "empty", + dataSources: []string{}, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &SchemaValidatorOptions{} + + WithExcludedDataSources(tt.dataSources...)(opts) + + if len(opts.ExcludedDataSources) != tt.wantLen { + t.Errorf("ExcludedDataSources length = %d, want %d", len(opts.ExcludedDataSources), tt.wantLen) + } + }) + } +} + +func TestOptionChaining(t *testing.T) { + opts := &SchemaValidatorOptions{} + + // Apply multiple options + WithTerraformRoot("/test")(opts) + WithExcludedResources("resource1", "resource2")(opts) + WithExcludedDataSources("data1")(opts) + WithGitHubIssueCreation()(opts) + customParser := &DefaultHCLParser{} + customRunner := &DefaultTerraformRunner{} + WithParser(customParser)(opts) + WithTerraformRunner(customRunner)(opts) + + if opts.TerraformRoot != "/test" { + t.Error("TerraformRoot not set correctly") + } + + if len(opts.ExcludedResources) != 2 { + t.Errorf("ExcludedResources length = %d, want 2", len(opts.ExcludedResources)) + } + + if len(opts.ExcludedDataSources) != 1 { + t.Errorf("ExcludedDataSources length = %d, want 1", len(opts.ExcludedDataSources)) + } + + if !opts.CreateGitHubIssue { + t.Error("CreateGitHubIssue not set correctly") + } + + if opts.Parser != customParser { + t.Error("Parser not set correctly") + } + + if opts.TerraformRunner != customRunner { + t.Error("TerraformRunner not set correctly") + } +} diff --git a/diffy.go b/diffy.go index f8e21ee..cbdd8a3 100644 --- a/diffy.go +++ b/diffy.go @@ -71,8 +71,15 @@ func validateProject(opts *SchemaValidatorOptions) ([]ValidationFinding, error) return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", opts.TerraformRoot, err) } - parser := NewHCLParser() - runner := NewTerraformRunner() + parser := opts.Parser + if parser == nil { + parser = NewHCLParser() + } + + runner := opts.TerraformRunner + if runner == nil { + runner = NewTerraformRunner() + } rootFindings, err := ValidateTerraformSchemaWithOptions( opts.Logger, diff --git a/diffy_validate_test.go b/diffy_validate_test.go new file mode 100644 index 0000000..e37cd92 --- /dev/null +++ b/diffy_validate_test.go @@ -0,0 +1,176 @@ +package diffy + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateSchemaUsesInjectedParserAndRunner(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "main.tf"), "# test") + + parser := &validateStubParser{ + providerSource: "registry.terraform.io/hashicorp/azurerm", + resources: nil, + } + runner := &validateStubRunner{ + schema: &TerraformSchema{ + ProviderSchemas: map[string]*ProviderSchema{ + parser.providerSource: { + ResourceSchemas: map[string]*ResourceSchema{}, + DataSourceSchemas: map[string]*ResourceSchema{}, + }, + }, + }, + } + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + orig := os.Stdout + defer func() { os.Stdout = orig }() + os.Stdout = w + + findings, err := ValidateSchema( + WithTerraformRoot(root), + WithParser(parser), + WithTerraformRunner(runner), + func(opts *SchemaValidatorOptions) { + opts.Logger = &SimpleLogger{} + opts.Silent = false + }, + ) + if err != nil { + t.Fatalf("ValidateSchema returned error: %v", err) + } + w.Close() + var out bytes.Buffer + if _, err := out.ReadFrom(r); err != nil { + t.Fatalf("failed to read output: %v", err) + } + + if len(findings) != 0 { + t.Fatalf("expected no findings, got %d", len(findings)) + } + + if got := out.String(); got == "" || !contains(got, "No validation findings.") { + t.Fatalf("outputFindings should write success message, got %q", got) + } +} + +func TestCreateGitHubIssueRequiresToken(t *testing.T) { + opts := &SchemaValidatorOptions{ + GitHubOwner: "owner", + GitHubRepo: "repo", + } + if err := createGitHubIssue(context.Background(), opts, nil); err == nil { + t.Fatalf("expected token error") + } +} + +func TestValidateSchemaMissingRootReturnsError(t *testing.T) { + if _, err := ValidateSchema(); err == nil { + t.Fatalf("expected error when Terraform root not provided") + } +} + +func TestValidateSchemaUsesEnvRoot(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "main.tf"), "# test") + t.Setenv("TERRAFORM_ROOT", root) + + parser := &validateStubParser{providerSource: "registry.terraform.io/hashicorp/azurerm"} + runner := &validateStubRunner{ + schema: &TerraformSchema{ + ProviderSchemas: map[string]*ProviderSchema{ + parser.providerSource: { + ResourceSchemas: map[string]*ResourceSchema{}, + DataSourceSchemas: map[string]*ResourceSchema{}, + }, + }, + }, + } + + if _, err := ValidateSchema( + WithParser(parser), + WithTerraformRunner(runner), + ); err != nil { + t.Fatalf("ValidateSchema returned error: %v", err) + } +} + +func TestValidateTerraformSchemaWithOptionsPropagatesInitError(t *testing.T) { + parser := &validateStubParser{providerSource: "registry.terraform.io/hashicorp/azurerm"} + runner := &failingRunner{} + + _, err := ValidateTerraformSchemaWithOptions( + &SimpleLogger{}, + t.TempDir(), + "", + parser, + runner, + nil, + nil, + ) + if err == nil || !strings.Contains(err.Error(), "init failed") { + t.Fatalf("expected init failure, got %v", err) + } +} + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write file %s: %v", path, err) + } +} + +// stubParser and stubRunner mirror the lightweight fakes in validator tests. +type validateStubParser struct { + providerSource string + resources []ParsedResource +} + +func (p *validateStubParser) ParseProviderRequirements(_ context.Context, _ string) (map[string]ProviderConfig, error) { + return map[string]ProviderConfig{ + "azurerm": {Source: p.providerSource}, + }, nil +} + +func (p *validateStubParser) ParseMainFile(ctx context.Context, filename string) ([]ParsedResource, []ParsedDataSource, error) { + return p.ParseTerraformFiles(ctx, []string{filename}) +} + +func (p *validateStubParser) ParseTerraformFiles(_ context.Context, _ []string) ([]ParsedResource, []ParsedDataSource, error) { + return p.resources, nil, nil +} + +type validateStubRunner struct { + schema *TerraformSchema +} + +func (r *validateStubRunner) Init(_ context.Context, _ string) error { + return nil +} + +func (r *validateStubRunner) GetSchema(_ context.Context, _ string) (*TerraformSchema, error) { + if r.schema == nil { + return nil, fmt.Errorf("no schema") + } + return r.schema, nil +} + +type failingRunner struct{} + +func (f *failingRunner) Init(_ context.Context, _ string) error { + return fmt.Errorf("init failed") +} + +func (f *failingRunner) GetSchema(_ context.Context, _ string) (*TerraformSchema, error) { + return nil, fmt.Errorf("should not be called") +} diff --git a/example_usage_test.go b/example_usage_test.go new file mode 100644 index 0000000..7460d18 --- /dev/null +++ b/example_usage_test.go @@ -0,0 +1,77 @@ +package diffy + +import ( + "context" + "path/filepath" + "testing" +) + +// Ensures the HCL in examples/module still parses and validates against a minimal schema. +func TestExampleModuleValidatesWithStubRunner(t *testing.T) { + root := filepath.Join("examples", "module") + + parser := &exampleStubParser{providerSource: "registry.terraform.io/hashicorp/azurerm"} + runner := &exampleStubRunner{ + schema: &TerraformSchema{ + ProviderSchemas: map[string]*ProviderSchema{ + parser.providerSource: { + ResourceSchemas: map[string]*ResourceSchema{ + "azurerm_linux_function_app": { + Block: &SchemaBlock{ + Attributes: map[string]*SchemaAttribute{}, + BlockTypes: map[string]*SchemaBlockType{}, + }, + }, + }, + DataSourceSchemas: map[string]*ResourceSchema{}, + }, + }, + }, + } + + findings, err := ValidateSchema( + WithTerraformRoot(root), + WithParser(parser), + WithTerraformRunner(runner), + func(opts *SchemaValidatorOptions) { + opts.Silent = true + }, + ) + if err != nil { + t.Fatalf("ValidateSchema returned error: %v", err) + } + if len(findings) > 0 { + t.Fatalf("example validation produced %d findings: %+v", len(findings), findings) + } +} + +type exampleStubParser struct { + providerSource string + resources []ParsedResource +} + +func (p *exampleStubParser) ParseProviderRequirements(_ context.Context, _ string) (map[string]ProviderConfig, error) { + return map[string]ProviderConfig{ + "azurerm": {Source: p.providerSource}, + }, nil +} + +func (p *exampleStubParser) ParseMainFile(ctx context.Context, filename string) ([]ParsedResource, []ParsedDataSource, error) { + return p.ParseTerraformFiles(ctx, []string{filename}) +} + +func (p *exampleStubParser) ParseTerraformFiles(_ context.Context, _ []string) ([]ParsedResource, []ParsedDataSource, error) { + return p.resources, nil, nil +} + +type exampleStubRunner struct { + schema *TerraformSchema +} + +func (r *exampleStubRunner) Init(_ context.Context, _ string) error { + return nil +} + +func (r *exampleStubRunner) GetSchema(_ context.Context, _ string) (*TerraformSchema, error) { + return r.schema, nil +} diff --git a/go.mod b/go.mod index 8cc13fc..928209f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/dkooll/diffy go 1.24.1 require ( + github.com/google/go-cmp v0.6.0 github.com/hashicorp/hcl/v2 v2.23.0 github.com/zclconf/go-cty v1.16.2 ) diff --git a/issue_test.go b/issue_test.go new file mode 100644 index 0000000..42b3e0a --- /dev/null +++ b/issue_test.go @@ -0,0 +1,196 @@ +package diffy + +import ( + "bytes" + "context" + "io" + "net/http" + "strings" + "testing" +) + +func TestCreateOrUpdateIssue_CreatesNew(t *testing.T) { + var calls []recordedCall + client := newStubHTTPClient(t, &calls, []httpHandlerStep{ + { + method: "GET", + path: "/repos/o/r/issues", + status: http.StatusOK, + body: `[]`, + }, + { + method: "POST", + path: "/repos/o/r/issues", + status: http.StatusCreated, + check: func(t *testing.T, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "token TOKEN" { + t.Fatalf("expected auth header, got %q", got) + } + }, + }, + }) + + manager := &GitHubIssueManager{ + GitHubConfig: GitHubConfig{ + RepoOwner: "o", + RepoName: "r", + Token: "TOKEN", + }, + Client: client, + } + + findings := []ValidationFinding{ + {ResourceType: "r1", Path: "root.attr", Name: "foo", Required: true}, + {ResourceType: "r1", Path: "root.attr", Name: "foo", Required: true}, // duplicate should be deduped + } + + if err := manager.CreateOrUpdateIssue(context.Background(), findings); err != nil { + t.Fatalf("CreateOrUpdateIssue returned error: %v", err) + } + + if len(calls) != 2 { + t.Fatalf("expected 2 API calls, got %d", len(calls)) + } + + body := calls[1].body.String() + if !strings.Contains(body, "r1") || !strings.Contains(body, "`foo`") { + t.Fatalf("issue body missing content: %q", body) + } +} + +func TestCreateOrUpdateIssue_UpdatesExisting(t *testing.T) { + var calls []recordedCall + client := newStubHTTPClient(t, &calls, []httpHandlerStep{ + { + method: "GET", + path: "/repos/o/r/issues", + status: http.StatusOK, + body: `[{"number":5,"title":"Generated schema validation","body":""}]`, + }, + { + method: "PATCH", + path: "/repos/o/r/issues/5", + status: http.StatusOK, + }, + }) + + manager := &GitHubIssueManager{ + GitHubConfig: GitHubConfig{ + RepoOwner: "o", + RepoName: "r", + Token: "TOKEN", + }, + Client: client, + } + + if err := manager.CreateOrUpdateIssue(context.Background(), []ValidationFinding{ + {ResourceType: "r1", Path: "root.attr", Name: "bar"}, + }); err != nil { + t.Fatalf("CreateOrUpdateIssue returned error: %v", err) + } + + if len(calls) != 2 { + t.Fatalf("expected 2 API calls, got %d", len(calls)) + } +} + +func TestCloseExistingIssuesIfEmpty_AddsCommentAndCloses(t *testing.T) { + var calls []recordedCall + client := newStubHTTPClient(t, &calls, []httpHandlerStep{ + { + method: "GET", + path: "/repos/o/r/issues", + status: http.StatusOK, + body: `[{"number":9,"title":"Generated schema validation","body":""}]`, + }, + { + method: "POST", + path: "/repos/o/r/issues/9/comments", + status: http.StatusCreated, + }, + { + method: "PATCH", + path: "/repos/o/r/issues/9", + status: http.StatusOK, + }, + }) + + manager := &GitHubIssueManager{ + GitHubConfig: GitHubConfig{ + RepoOwner: "o", + RepoName: "r", + Token: "TOKEN", + }, + Client: client, + } + + if err := manager.CloseExistingIssuesIfEmpty(context.Background()); err != nil { + t.Fatalf("CloseExistingIssuesIfEmpty returned error: %v", err) + } + + if len(calls) != 3 { + t.Fatalf("expected 3 API calls, got %d", len(calls)) + } +} + +// --- helpers --- + +type recordedCall struct { + method string + path string + body bytes.Buffer +} + +type httpHandlerStep struct { + method string + path string + status int + body string + check func(*testing.T, *http.Request) +} + +type stubTransport struct { + t *testing.T + steps []httpHandlerStep + calls *[]recordedCall +} + +func (st *stubTransport) RoundTrip(r *http.Request) (*http.Response, error) { + if len(st.steps) == 0 { + st.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + step := st.steps[0] + st.steps = st.steps[1:] + + if r.Method != step.method || r.URL.Path != step.path { + st.t.Fatalf("expected %s %s, got %s %s", step.method, step.path, r.Method, r.URL.Path) + } + + var buf bytes.Buffer + if r.Body != nil { + _, _ = buf.ReadFrom(r.Body) + } + *st.calls = append(*st.calls, recordedCall{method: r.Method, path: r.URL.Path, body: buf}) + + if step.check != nil { + step.check(st.t, r) + } + + resp := &http.Response{ + StatusCode: step.status, + Body: io.NopCloser(strings.NewReader(step.body)), + Header: make(http.Header), + } + return resp, nil +} + +func newStubHTTPClient(t *testing.T, calls *[]recordedCall, steps []httpHandlerStep) *http.Client { + t.Helper() + return &http.Client{ + Transport: &stubTransport{ + t: t, + steps: steps, + calls: calls, + }, + } +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..50ab299 --- /dev/null +++ b/logger_test.go @@ -0,0 +1,144 @@ +package diffy + +import ( + "strings" + "testing" +) + +func TestSimpleLogger_Logf(t *testing.T) { + tests := []struct { + name string + format string + args []any + want string + }{ + { + name: "simple message", + format: "Test message", + args: []any{}, + want: "Test message", + }, + { + name: "formatted message", + format: "Resource %s has %d issues", + args: []any{"azurerm_virtual_network", 3}, + want: "Resource azurerm_virtual_network has 3 issues", + }, + { + name: "multiple arguments", + format: "%s.%s missing attribute %s", + args: []any{"azurerm_virtual_network", "test", "location"}, + want: "azurerm_virtual_network.test missing attribute location", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := &SimpleLogger{} + + // Redirect output (we can't easily capture fmt.Printf, but we can test the interface) + logger.Logf(tt.format, tt.args...) + + // Since SimpleLogger uses fmt.Printf, we just verify it doesn't panic + // In a real scenario, you'd use a mock or capture stdout + }) + } +} + +func TestSimpleLogger_Interface(t *testing.T) { + var _ Logger = (*SimpleLogger)(nil) + + logger := &SimpleLogger{} + + // Test that it implements the Logger interface + logger.Logf("Test message") + logger.Logf("Formatted: %s", "value") + logger.Logf("Multiple: %s %d %v", "test", 42, true) +} + +func TestLoggerUsage(t *testing.T) { + logger := &SimpleLogger{} + + // Test various logging scenarios + testCases := []struct { + name string + logFn func() + }{ + { + name: "validation start", + logFn: func() { + logger.Logf("Starting validation for directory: %s", "/test/path") + }, + }, + { + name: "finding logged", + logFn: func() { + logger.Logf("[%s] %s.%s is missing %s", "Resource", "azurerm_vnet", "test", "location") + }, + }, + { + name: "completion message", + logFn: func() { + logger.Logf("Validation complete. Found %d issues", 5) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Just ensure logging doesn't panic + tc.logFn() + }) + } +} + +// MockLogger for testing +type MockLogger struct { + messages []string +} + +func (m *MockLogger) Logf(format string, args ...any) { + msg := format + if len(args) > 0 { + // Simple formatting for testing + msg = strings.TrimSpace(format) + } + m.messages = append(m.messages, msg) +} + +func TestMockLogger(t *testing.T) { + mock := &MockLogger{ + messages: make([]string, 0), + } + + mock.Logf("First message") + mock.Logf("Second message: %s", "test") + mock.Logf("Third message") + + if len(mock.messages) != 3 { + t.Errorf("Expected 3 messages, got %d", len(mock.messages)) + } + + if mock.messages[0] != "First message" { + t.Errorf("First message = %q, want %q", mock.messages[0], "First message") + } +} + +func TestLoggerWithValidator(t *testing.T) { + mock := &MockLogger{ + messages: make([]string, 0), + } + + validator := NewSchemaValidator(mock) + + if validator.logger != mock { + t.Error("Validator should use the provided logger") + } + + // Verify logger is set + mock.Logf("Validator created with logger") + + if len(mock.messages) != 1 { + t.Errorf("Expected 1 message, got %d", len(mock.messages)) + } +} diff --git a/middleware_test.go b/middleware_test.go new file mode 100644 index 0000000..95f012a --- /dev/null +++ b/middleware_test.go @@ -0,0 +1,145 @@ +package diffy + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +func TestLoggingMiddleware_LogsErrors(t *testing.T) { + logger := &stubLogger{} + mw := LoggingMiddleware(logger) + + sentinel := errors.New("boom") + findings, err := mw(nil, sentinel) + + if !errors.Is(err, sentinel) { + t.Fatalf("expected error to be returned unchanged") + } + + if len(findings) != 0 { + t.Fatalf("expected findings to pass through unchanged, got %d items", len(findings)) + } + + if !logger.contains("Validation failed") { + t.Fatalf("expected failure message to be logged, got %v", logger.messages) + } +} + +func TestLoggingMiddleware_LogsCounts(t *testing.T) { + tests := []struct { + name string + findings []ValidationFinding + wantSubstring string + }{ + { + name: "no findings", + findings: nil, + wantSubstring: "no issues found", + }, + { + name: "with findings", + findings: []ValidationFinding{ + {ResourceType: "azurerm_virtual_network", Path: "test", Name: "name"}, + {ResourceType: "azurerm_virtual_network", Path: "test", Name: "subnet", IsBlock: true}, + }, + wantSubstring: "found 2 issues", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := &stubLogger{} + mw := LoggingMiddleware(logger) + + findings, err := mw(tt.findings, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(findings) != len(tt.findings) { + t.Fatalf("findings should pass through unchanged, got %d items", len(findings)) + } + + if !logger.contains(tt.wantSubstring) { + t.Fatalf("expected log to contain %q, got %v", tt.wantSubstring, logger.messages) + } + }) + } +} + +func TestApplyMiddlewareInvokesWrapper(t *testing.T) { + logger := &stubLogger{} + wrapped := LoggingMiddleware(logger) + + findings, err := ApplyMiddleware([]ValidationFinding{{Name: "name"}}, nil, wrapped) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(findings) != 1 || findings[0].Name != "name" { + t.Fatalf("findings should be returned unchanged, got %#v", findings) + } + + if len(logger.messages) == 0 { + t.Fatalf("middleware should log through provided logger") + } +} + +type stubLogger struct { + messages []string +} + +func (l *stubLogger) Logf(format string, args ...any) { + l.messages = append(l.messages, fmt.Sprintf(format, args...)) +} + +func (l *stubLogger) contains(substr string) bool { + for _, m := range l.messages { + if strings.Contains(m, substr) { + return true + } + } + return false +} + +func TestParsedDataSource_Structure(t *testing.T) { + pds := ParsedDataSource{ + Type: "azurerm_resource_group", + Name: "existing", + Data: BlockData{ + Properties: map[string]bool{"name": true}, + StaticBlocks: make(map[string][]*ParsedBlock), + DynamicBlocks: make(map[string]*ParsedBlock), + IgnoreChanges: []string{}, + }, + } + + if pds.Type != "azurerm_resource_group" { + t.Errorf("Type = %s, want azurerm_resource_group", pds.Type) + } + + if pds.Name != "existing" { + t.Errorf("Name = %s, want existing", pds.Name) + } + + if !pds.Data.Properties["name"] { + t.Error("Data should have 'name' property") + } +} + +func TestSubModule_Structure(t *testing.T) { + sm := SubModule{ + Name: "network", + Path: "/path/to/modules/network", + } + + if sm.Name != "network" { + t.Errorf("Name = %s, want network", sm.Name) + } + + if sm.Path != "/path/to/modules/network" { + t.Errorf("Path = %s, want /path/to/modules/network", sm.Path) + } +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..4583be4 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,390 @@ +package diffy + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestNormalizeSource(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + { + name: "full source path", + source: "registry.terraform.io/hashicorp/azurerm", + want: "registry.terraform.io/hashicorp/azurerm", + }, + { + name: "short source path", + source: "hashicorp/azurerm", + want: "registry.terraform.io/hashicorp/azurerm", + }, + { + name: "single name without slash", + source: "azurerm", + want: "azurerm", // No slash, returns as-is + }, + { + name: "custom registry with slash", + source: "custom.registry.io/myorg/myprovider", + want: "registry.terraform.io/custom.registry.io/myorg/myprovider", // Has slash, gets prefixed + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NormalizeSource(tt.source) + if got != tt.want { + t.Errorf("NormalizeSource(%q) = %q, want %q", tt.source, got, tt.want) + } + }) + } +} + +func TestFindSubmodules(t *testing.T) { + tests := []struct { + name string + structure map[string]bool // path -> isDir + wantCount int + wantErr bool + }{ + { + name: "single submodule", + structure: map[string]bool{ + "network": true, + "network/main.tf": false, + }, + wantCount: 1, + wantErr: false, + }, + { + name: "multiple submodules", + structure: map[string]bool{ + "network": true, + "network/main.tf": false, + "storage": true, + "storage/main.tf": false, + }, + wantCount: 2, + wantErr: false, + }, + { + name: "no submodules", + structure: map[string]bool{}, + wantCount: 0, + wantErr: false, // FindSubmodules returns nil error even if directory doesn't exist + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + for path, isDir := range tt.structure { + fullPath := filepath.Join(tmpDir, path) + if isDir { + if err := os.MkdirAll(fullPath, 0755); err != nil { + t.Fatalf("Failed to create directory %s: %v", path, err) + } + } else { + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create parent directory for %s: %v", path, err) + } + if err := os.WriteFile(fullPath, []byte("# test file"), 0644); err != nil { + t.Fatalf("Failed to create file %s: %v", path, err) + } + } + } + + got, err := FindSubmodules(tmpDir) + + if (err != nil) != tt.wantErr { + t.Errorf("FindSubmodules() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && len(got) != tt.wantCount { + t.Errorf("FindSubmodules() returned %d submodules, want %d", len(got), tt.wantCount) + } + }) + } +} + +func TestExtractIgnoreChangesFromValue(t *testing.T) { + val := cty.ListVal([]cty.Value{ + cty.StringVal("name"), + cty.StringVal("all"), + cty.StringVal("tags"), + }) + + got := extractIgnoreChangesFromValue(val) + + if len(got) != 1 || got[0] != "*all*" { + t.Fatalf("extractIgnoreChangesFromValue returned %v, want [*all*]", got) + } +} + +func TestParseProviderRequirements(t *testing.T) { + tests := []struct { + name string + tfContent string + wantErr bool + wantCount int + checkResult func(*testing.T, map[string]ProviderConfig) + }{ + { + name: "single provider", + tfContent: ` +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0.0" + } + } +} +`, + wantErr: false, + wantCount: 1, + checkResult: func(t *testing.T, providers map[string]ProviderConfig) { + if provider, ok := providers["azurerm"]; ok { + if provider.Source != "registry.terraform.io/hashicorp/azurerm" { + t.Errorf("Source = %s, want registry.terraform.io/hashicorp/azurerm", provider.Source) + } + if provider.Version != ">= 3.0.0" { + t.Errorf("Version = %s, want >= 3.0.0", provider.Version) + } + } else { + t.Error("azurerm provider not found") + } + }, + }, + { + name: "multiple providers", + tfContent: ` +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.1" + } + } +} +`, + wantErr: false, + wantCount: 2, + checkResult: func(t *testing.T, providers map[string]ProviderConfig) { + if len(providers) != 2 { + t.Errorf("Got %d providers, want 2", len(providers)) + } + }, + }, + { + name: "no terraform block", + tfContent: ` +resource "azurerm_virtual_network" "test" { + name = "test" +} +`, + wantErr: false, + wantCount: 0, + checkResult: func(t *testing.T, providers map[string]ProviderConfig) { + if len(providers) != 0 { + t.Errorf("Got %d providers, want 0", len(providers)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + tfFile := filepath.Join(tmpDir, "main.tf") + if err := os.WriteFile(tfFile, []byte(tt.tfContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + parser := NewHCLParser() + ctx := context.Background() + got, err := parser.ParseProviderRequirements(ctx, tfFile) + + if (err != nil) != tt.wantErr { + t.Errorf("ParseProviderRequirements() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + if len(got) != tt.wantCount { + t.Errorf("Got %d providers, want %d", len(got), tt.wantCount) + } + if tt.checkResult != nil { + tt.checkResult(t, got) + } + } + }) + } +} + +func TestParseTerraformFiles(t *testing.T) { + tests := []struct { + name string + files map[string]string + wantResources int + wantDataSources int + wantErr bool + }{ + { + name: "single resource", + files: map[string]string{ + "main.tf": ` +resource "azurerm_virtual_network" "test" { + name = "test-vnet" + resource_group_name = "test-rg" + location = "westeurope" + address_space = ["10.0.0.0/16"] +} +`, + }, + wantResources: 1, + wantDataSources: 0, + wantErr: false, + }, + { + name: "resources and data sources", + files: map[string]string{ + "main.tf": ` +resource "azurerm_virtual_network" "test" { + name = "test" +} + +data "azurerm_resource_group" "existing" { + name = "existing-rg" +} +`, + }, + wantResources: 1, + wantDataSources: 1, + wantErr: false, + }, + { + name: "multiple files", + files: map[string]string{ + "main.tf": ` +resource "azurerm_virtual_network" "test1" { + name = "test1" +} +`, + "data.tf": ` +data "azurerm_resource_group" "existing" { + name = "existing" +} +`, + }, + wantResources: 1, + wantDataSources: 1, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + var fileList []string + + for filename, content := range tt.files { + tfFile := filepath.Join(tmpDir, filename) + if err := os.WriteFile(tfFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + fileList = append(fileList, tfFile) + } + + parser := NewHCLParser() + ctx := context.Background() + resources, dataSources, err := parser.ParseTerraformFiles(ctx, fileList) + + if (err != nil) != tt.wantErr { + t.Errorf("ParseTerraformFiles() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + if len(resources) != tt.wantResources { + t.Errorf("Got %d resources, want %d", len(resources), tt.wantResources) + } + if len(dataSources) != tt.wantDataSources { + t.Errorf("Got %d data sources, want %d", len(dataSources), tt.wantDataSources) + } + } + }) + } +} + +func TestParseMainFile(t *testing.T) { + tmpDir := t.TempDir() + tfFile := filepath.Join(tmpDir, "main.tf") + + content := ` +resource "azurerm_virtual_network" "test" { + name = "test" +} +` + if err := os.WriteFile(tfFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + parser := NewHCLParser() + ctx := context.Background() + resources, dataSources, err := parser.ParseMainFile(ctx, tfFile) + + if err != nil { + t.Errorf("ParseMainFile() error = %v", err) + } + + if len(resources) != 1 { + t.Errorf("Got %d resources, want 1", len(resources)) + } + + if len(dataSources) != 0 { + t.Errorf("Got %d data sources, want 0", len(dataSources)) + } +} + +func TestParseHCLFile_InvalidFile(t *testing.T) { + parser := NewHCLParser() + ctx := context.Background() + + _, _, err := parser.ParseMainFile(ctx, "/non/existent/file.tf") + if err == nil { + t.Error("ParseMainFile() should return error for non-existent file") + } +} + +func TestParseHCLFile_InvalidSyntax(t *testing.T) { + tmpDir := t.TempDir() + tfFile := filepath.Join(tmpDir, "invalid.tf") + + content := `resource "test" {` + if err := os.WriteFile(tfFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + parser := NewHCLParser() + ctx := context.Background() + + _, _, err := parser.ParseMainFile(ctx, tfFile) + if err == nil { + t.Error("ParseMainFile() should return error for invalid HCL syntax") + } +} diff --git a/repo_test.go b/repo_test.go new file mode 100644 index 0000000..f9241d7 --- /dev/null +++ b/repo_test.go @@ -0,0 +1,136 @@ +package diffy + +import ( + "os" + "testing" +) + +func TestNewGitRepoInfo(t *testing.T) { + terraformRoot := "/test/terraform/root" + repoInfo := NewGitRepoInfo(terraformRoot) + + if repoInfo == nil { + t.Fatal("NewGitRepoInfo() should not return nil") + } + + if repoInfo.TerraformRoot != terraformRoot { + t.Errorf("TerraformRoot = %s, want %s", repoInfo.TerraformRoot, terraformRoot) + } +} + +func TestGitRepoInfo_GetRepoInfo_FromEnv(t *testing.T) { + tests := []struct { + name string + envOwner string + envRepo string + envCombined string + wantOwner string + wantRepo string + }{ + { + name: "from separate env vars", + envOwner: "testowner", + envRepo: "testrepo", + wantOwner: "testowner", + wantRepo: "testrepo", + }, + { + name: "from combined GITHUB_REPOSITORY", + envCombined: "testowner/testrepo", + wantOwner: "testowner", + wantRepo: "testrepo", + }, + { + name: "no env vars set", + wantOwner: "", + wantRepo: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear env vars + os.Unsetenv("GITHUB_REPOSITORY_OWNER") + os.Unsetenv("GITHUB_REPOSITORY_NAME") + os.Unsetenv("GITHUB_REPOSITORY") + + // Set test env vars + if tt.envOwner != "" { + os.Setenv("GITHUB_REPOSITORY_OWNER", tt.envOwner) + } + if tt.envRepo != "" { + os.Setenv("GITHUB_REPOSITORY_NAME", tt.envRepo) + } + if tt.envCombined != "" { + os.Setenv("GITHUB_REPOSITORY", tt.envCombined) + } + + repoInfo := NewGitRepoInfo("/test") + owner, repo := repoInfo.GetRepoInfo() + + if owner != tt.wantOwner { + t.Errorf("owner = %q, want %q", owner, tt.wantOwner) + } + + if repo != tt.wantRepo { + t.Errorf("repo = %q, want %q", repo, tt.wantRepo) + } + + // Clean up + os.Unsetenv("GITHUB_REPOSITORY_OWNER") + os.Unsetenv("GITHUB_REPOSITORY_NAME") + os.Unsetenv("GITHUB_REPOSITORY") + }) + } +} + +func TestRepositoryInfoProvider_Interface(t *testing.T) { + // Test that GitRepoInfo implements RepositoryInfoProvider interface + var _ RepositoryInfoProvider = (*GitRepoInfo)(nil) + + repoInfo := NewGitRepoInfo("/test") + + // Should not panic + owner, repo := repoInfo.GetRepoInfo() + + // Just verify it returns something (even if empty) + t.Logf("GetRepoInfo() returned owner=%q, repo=%q", owner, repo) +} + +func TestGitRepoInfo_MultipleCallsConsistency(t *testing.T) { + // Set consistent env vars + os.Setenv("GITHUB_REPOSITORY_OWNER", "testowner") + os.Setenv("GITHUB_REPOSITORY_NAME", "testrepo") + defer func() { + os.Unsetenv("GITHUB_REPOSITORY_OWNER") + os.Unsetenv("GITHUB_REPOSITORY_NAME") + }() + + repoInfo := NewGitRepoInfo("/test") + + // Test multiple calls return consistent results + owner1, repo1 := repoInfo.GetRepoInfo() + owner2, repo2 := repoInfo.GetRepoInfo() + + if owner1 != owner2 { + t.Errorf("Multiple calls should return same owner: %q vs %q", owner1, owner2) + } + + if repo1 != repo2 { + t.Errorf("Multiple calls should return same repo: %q vs %q", repo1, repo2) + } +} + +func TestGitRepoInfo_InvalidGitHubRepository(t *testing.T) { + // Set invalid GITHUB_REPOSITORY value + os.Setenv("GITHUB_REPOSITORY", "invalid-no-slash") + defer os.Unsetenv("GITHUB_REPOSITORY") + + repoInfo := NewGitRepoInfo("/test") + owner, repo := repoInfo.GetRepoInfo() + + // Should return empty strings for invalid format + if owner != "" || repo != "" { + t.Errorf("Invalid GITHUB_REPOSITORY should return empty, got owner=%q, repo=%q", owner, repo) + } +} diff --git a/runner_test.go b/runner_test.go new file mode 100644 index 0000000..7976dfc --- /dev/null +++ b/runner_test.go @@ -0,0 +1,195 @@ +package diffy + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDefaultTerraformRunnerInitCachesByDir(t *testing.T) { + helperDir := t.TempDir() + logFile := filepath.Join(helperDir, "log.txt") + script := filepath.Join(helperDir, "terraform") + + writeExecutable(t, script, `#!/bin/sh +echo "init:$PWD" >> "`+logFile+`" +`) + + t.Setenv("PATH", helperDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + dir := t.TempDir() + + runner := NewTerraformRunner() + + if err := runner.Init(context.Background(), dir); err != nil { + t.Fatalf("Init returned error: %v", err) + } + if err := runner.Init(context.Background(), dir); err != nil { + t.Fatalf("second Init returned error: %v", err) + } + + logContent := readFile(t, logFile) + lines := strings.Split(strings.TrimSpace(logContent), "\n") + if len(lines) != 1 { + t.Fatalf("expected terraform init to run once, got %d entries: %q", len(lines), logContent) + } + if !strings.Contains(lines[0], dir) { + t.Fatalf("log entry should include working dir %s, got %q", dir, lines[0]) + } +} + +func TestDefaultTerraformRunnerGetSchemaCachesResult(t *testing.T) { + helperDir := t.TempDir() + logFile := filepath.Join(helperDir, "log.txt") + script := filepath.Join(helperDir, "terraform") + + writeExecutable(t, script, `#!/bin/sh +if [ "$1" = "providers" ]; then + echo '{"provider_schemas":{"registry.terraform.io/hashicorp/azurerm":{"resource_schemas":{},"data_source_schemas":{}}}}' + echo "schema:$PWD" >> "`+logFile+`" + exit 0 +fi +echo "unexpected args: $@" >&2 +exit 1 +`) + + t.Setenv("PATH", helperDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + dir := t.TempDir() + runner := NewTerraformRunner() + + s1, err := runner.GetSchema(context.Background(), dir) + if err != nil { + t.Fatalf("GetSchema returned error: %v", err) + } + if s1 == nil || len(s1.ProviderSchemas) == 0 { + t.Fatalf("schema should be populated") + } + + s2, err := runner.GetSchema(context.Background(), dir) + if err != nil { + t.Fatalf("second GetSchema returned error: %v", err) + } + + if s1 != s2 { + t.Fatalf("expected cached schema pointer, got different instances") + } + + logContent := strings.TrimSpace(readFile(t, logFile)) + if logContent == "" { + t.Fatalf("expected terraform providers schema command to run once") + } + if strings.Count(logContent, "\n")+1 != 1 { + t.Fatalf("expected single schema invocation, got log: %q", logContent) + } +} + +func TestValidateTerraformSchemaInDirectory_NoMain(t *testing.T) { + dir := t.TempDir() + findings, err := ValidateTerraformSchemaInDirectory(&SimpleLogger{}, dir, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(findings) != 0 { + t.Fatalf("expected no findings when main.tf is absent, got %d", len(findings)) + } +} + +func TestValidateTerraformSchemaInDirectoryWithOptions_UsesTerraformCLI(t *testing.T) { + helperDir := t.TempDir() + logFile := filepath.Join(helperDir, "tf.log") + script := filepath.Join(helperDir, "terraform") + + writeExecutable(t, script, `#!/bin/sh +echo "$1" >> "`+logFile+`" +if [ "$1" = "init" ]; then + exit 0 +fi +if [ "$1" = "providers" ] && [ "$2" = "schema" ]; then + cat <<'EOF' +{"provider_schemas":{"registry.terraform.io/hashicorp/azurerm":{"resource_schemas":{"azurerm_resource_group":{"block":{"attributes":{"name":{"required":true},"location":{"required":true}},"block_types":{}}}},"data_source_schemas":{}}}} +EOF + exit 0 +fi +echo "unexpected args: $@" >&2 +exit 1 +`) + t.Setenv("PATH", helperDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "main.tf"), ` +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +resource "azurerm_resource_group" "rg" { + name = "rg1" + location = "westeurope" +} +`) + + findings, err := ValidateTerraformSchemaInDirectoryWithOptions(&SimpleLogger{}, dir, "", nil, nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(findings) != 0 { + t.Fatalf("expected no findings, got %d", len(findings)) + } + + logs := strings.Split(strings.TrimSpace(readFile(t, logFile)), "\n") + if len(logs) != 2 || logs[0] != "init" || logs[1] != "providers" { + t.Fatalf("expected init and providers schema calls, got %v", logs) + } +} + +func TestDefaultTerraformRunnerInitError(t *testing.T) { + helperDir := t.TempDir() + script := filepath.Join(helperDir, "terraform") + writeExecutable(t, script, `#!/bin/sh +echo "boom" >&2 +exit 2 +`) + t.Setenv("PATH", helperDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + runner := NewTerraformRunner() + if err := runner.Init(context.Background(), t.TempDir()); err == nil { + t.Fatalf("expected init error") + } +} + +func TestDefaultTerraformRunnerGetSchemaError(t *testing.T) { + helperDir := t.TempDir() + script := filepath.Join(helperDir, "terraform") + writeExecutable(t, script, `#!/bin/sh +exit 3 +`) + t.Setenv("PATH", helperDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + runner := NewTerraformRunner() + if _, err := runner.GetSchema(context.Background(), t.TempDir()); err == nil { + t.Fatalf("expected schema error") + } +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("failed to write helper script: %v", err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + return string(data) +} diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..d9b1380 --- /dev/null +++ b/types_test.go @@ -0,0 +1,162 @@ +package diffy + +import ( + "errors" + "testing" +) + +func TestParseError(t *testing.T) { + tests := []struct { + name string + err *ParseError + wantText []string + }{ + { + name: "error with nested error", + err: &ParseError{ + File: "main.tf", + Message: "invalid syntax", + Err: errors.New("unexpected token"), + }, + wantText: []string{"main.tf", "invalid syntax", "unexpected token"}, + }, + { + name: "error without nested error", + err: &ParseError{ + File: "variables.tf", + Message: "missing attribute", + }, + wantText: []string{"variables.tf", "missing attribute"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.Error() + + for _, want := range tt.wantText { + if !contains(got, want) { + t.Errorf("Error() = %q, should contain %q", got, want) + } + } + }) + } +} + +func TestParseError_Unwrap(t *testing.T) { + innerErr := errors.New("inner error") + parseErr := &ParseError{ + File: "test.tf", + Message: "test error", + Err: innerErr, + } + + unwrapped := parseErr.Unwrap() + if unwrapped != innerErr { + t.Errorf("Unwrap() = %v, want %v", unwrapped, innerErr) + } +} + +func TestValidationError(t *testing.T) { + tests := []struct { + name string + err *ValidationError + wantText []string + }{ + { + name: "error with nested error", + err: &ValidationError{ + ResourceType: "azurerm_virtual_network", + Message: "missing required attribute", + Err: errors.New("location is required"), + }, + wantText: []string{"azurerm_virtual_network", "missing required attribute", "location is required"}, + }, + { + name: "error without nested error", + err: &ValidationError{ + ResourceType: "azurerm_subnet", + Message: "invalid configuration", + }, + wantText: []string{"azurerm_subnet", "invalid configuration"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.Error() + + for _, want := range tt.wantText { + if !contains(got, want) { + t.Errorf("Error() = %q, should contain %q", got, want) + } + } + }) + } +} + +func TestValidationError_Unwrap(t *testing.T) { + innerErr := errors.New("inner error") + validationErr := &ValidationError{ + ResourceType: "test_resource", + Message: "test error", + Err: innerErr, + } + + unwrapped := validationErr.Unwrap() + if unwrapped != innerErr { + t.Errorf("Unwrap() = %v, want %v", unwrapped, innerErr) + } +} + +func TestGitHubError(t *testing.T) { + tests := []struct { + name string + err *GitHubError + wantText []string + }{ + { + name: "error with nested error", + err: &GitHubError{ + Operation: "create issue", + Message: "API request failed", + Err: errors.New("401 Unauthorized"), + }, + wantText: []string{"create issue", "API request failed", "401 Unauthorized"}, + }, + { + name: "error without nested error", + err: &GitHubError{ + Operation: "update issue", + Message: "issue not found", + }, + wantText: []string{"update issue", "issue not found"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.Error() + + for _, want := range tt.wantText { + if !contains(got, want) { + t.Errorf("Error() = %q, should contain %q", got, want) + } + } + }) + } +} + +func TestGitHubError_Unwrap(t *testing.T) { + innerErr := errors.New("inner error") + ghErr := &GitHubError{ + Operation: "test operation", + Message: "test error", + Err: innerErr, + } + + unwrapped := ghErr.Unwrap() + if unwrapped != innerErr { + t.Errorf("Unwrap() = %v, want %v", unwrapped, innerErr) + } +} diff --git a/validator_test.go b/validator_test.go new file mode 100644 index 0000000..31848eb --- /dev/null +++ b/validator_test.go @@ -0,0 +1,636 @@ +package diffy + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestDeduplicateFindings(t *testing.T) { + tests := []struct { + name string + findings []ValidationFinding + wantCount int + }{ + { + name: "no duplicates", + findings: []ValidationFinding{ + { + ResourceType: "azurerm_virtual_network", + Name: "test1", + Path: "location", + Required: true, + }, + { + ResourceType: "azurerm_virtual_network", + Name: "test2", + Path: "name", + Required: true, + }, + }, + wantCount: 2, + }, + { + name: "exact duplicates", + findings: []ValidationFinding{ + { + ResourceType: "azurerm_virtual_network", + Name: "test", + Path: "location", + Required: true, + }, + { + ResourceType: "azurerm_virtual_network", + Name: "test", + Path: "location", + Required: true, + }, + }, + wantCount: 1, + }, + { + name: "mixed duplicates and unique", + findings: []ValidationFinding{ + { + ResourceType: "azurerm_virtual_network", + Name: "test1", + Path: "location", + Required: true, + }, + { + ResourceType: "azurerm_virtual_network", + Name: "test1", + Path: "location", + Required: true, + }, + { + ResourceType: "azurerm_virtual_network", + Name: "test2", + Path: "name", + Required: true, + }, + }, + wantCount: 2, + }, + { + name: "empty findings", + findings: []ValidationFinding{}, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DeduplicateFindings(tt.findings) + if len(got) != tt.wantCount { + t.Errorf("DeduplicateFindings() returned %d findings, want %d", len(got), tt.wantCount) + } + }) + } +} + +func TestFormatFinding(t *testing.T) { + tests := []struct { + name string + finding ValidationFinding + wantContains []string + }{ + { + name: "resource attribute", + finding: ValidationFinding{ + ResourceType: "azurerm_virtual_network", + Name: "test", + Path: "location", + Required: true, + IsBlock: false, + }, + wantContains: []string{"azurerm_virtual_network", "test", "location"}, + }, + { + name: "resource block", + finding: ValidationFinding{ + ResourceType: "azurerm_virtual_network", + Name: "test", + Path: "subnet", + Required: true, + IsBlock: true, + }, + wantContains: []string{"azurerm_virtual_network", "test", "subnet"}, + }, + { + name: "data source attribute", + finding: ValidationFinding{ + ResourceType: "azurerm_virtual_network", + Name: "existing", + Path: "name", + Required: true, + IsDataSource: true, + }, + wantContains: []string{"azurerm_virtual_network", "existing", "name"}, + }, + { + name: "submodule data source", + finding: ValidationFinding{ + ResourceType: "azurerm_storage_account", + Name: "account", + Path: "root.block", + Required: false, + IsBlock: true, + IsDataSource: true, + SubmoduleName: "network", + }, + wantContains: []string{"azurerm_storage_account", "block", "submodule network", "data source"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatFinding(tt.finding) + + for _, want := range tt.wantContains { + if !contains(got, want) { + t.Errorf("FormatFinding() = %q, should contain %q", got, want) + } + } + }) + } +} + +func TestFilterResources(t *testing.T) { + tests := []struct { + name string + resources []ParsedResource + excluded []string + wantCount int + }{ + { + name: "no exclusions", + resources: []ParsedResource{ + {Type: "azurerm_virtual_network", Name: "test1"}, + {Type: "azurerm_subnet", Name: "test1"}, + }, + excluded: []string{}, + wantCount: 2, + }, + { + name: "exclude one type", + resources: []ParsedResource{ + {Type: "azurerm_virtual_network", Name: "test1"}, + {Type: "azurerm_subnet", Name: "test1"}, + }, + excluded: []string{"azurerm_subnet"}, + wantCount: 1, + }, + { + name: "exclude all", + resources: []ParsedResource{ + {Type: "azurerm_virtual_network", Name: "test1"}, + {Type: "azurerm_subnet", Name: "test1"}, + }, + excluded: []string{"azurerm_virtual_network", "azurerm_subnet"}, + wantCount: 0, + }, + { + name: "exact match exclusion", + resources: []ParsedResource{ + {Type: "azurerm_virtual_network", Name: "test1"}, + {Type: "azurerm_subnet", Name: "test1"}, + {Type: "azurerm_network_security_group", Name: "test1"}, + }, + excluded: []string{"azurerm_virtual_network"}, + wantCount: 2, // subnet and nsg remain + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filtered := filterResources(tt.resources, tt.excluded) + + if len(filtered) != tt.wantCount { + t.Errorf("filterResources() returned %d resources, want %d", len(filtered), tt.wantCount) + } + }) + } +} + +func TestFilterDataSources(t *testing.T) { + tests := []struct { + name string + dataSources []ParsedDataSource + excluded []string + wantCount int + }{ + { + name: "no exclusions", + dataSources: []ParsedDataSource{ + {Type: "azurerm_virtual_network", Name: "existing1"}, + {Type: "azurerm_subnet", Name: "existing1"}, + }, + excluded: []string{}, + wantCount: 2, + }, + { + name: "exclude one type", + dataSources: []ParsedDataSource{ + {Type: "azurerm_virtual_network", Name: "existing1"}, + {Type: "azurerm_subnet", Name: "existing1"}, + }, + excluded: []string{"azurerm_subnet"}, + wantCount: 1, + }, + { + name: "exclude all", + dataSources: []ParsedDataSource{ + {Type: "azurerm_virtual_network", Name: "existing1"}, + {Type: "azurerm_subnet", Name: "existing1"}, + }, + excluded: []string{"azurerm_virtual_network", "azurerm_subnet"}, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filtered := filterDataSources(tt.dataSources, tt.excluded) + + if len(filtered) != tt.wantCount { + t.Errorf("filterDataSources() returned %d data sources, want %d", len(filtered), tt.wantCount) + } + }) + } +} + +func TestValidateEntitiesMissingProviderOrSchema(t *testing.T) { + validator := NewSchemaValidator(&SimpleLogger{}) + + findings := validator.validateEntities( + []ParsedResource{{Type: "azurerm_virtual_network", Name: "test", Data: BlockData{}}}, + TerraformSchema{}, + map[string]ProviderConfig{}, // missing provider config, should log and skip + ".", + "", + false, + ) + + if len(findings) != 0 { + t.Fatalf("expected no findings when provider config missing, got %d", len(findings)) + } + + // Provider exists but schema missing + findings = validator.validateEntities( + []ParsedResource{{Type: "azurerm_virtual_network", Name: "test", Data: BlockData{}}}, + TerraformSchema{ + ProviderSchemas: map[string]*ProviderSchema{ + "registry.terraform.io/hashicorp/azurerm": { + ResourceSchemas: map[string]*ResourceSchema{}, + DataSourceSchemas: map[string]*ResourceSchema{}, + }, + }, + }, + map[string]ProviderConfig{"azurerm": {Source: "registry.terraform.io/hashicorp/azurerm"}}, + ".", + "", + false, + ) + + if len(findings) != 0 { + t.Fatalf("expected no findings when schema missing, got %d", len(findings)) + } +} + +func TestValidateBlocksMultipleStaticAndDynamic(t *testing.T) { + schema := &SchemaBlock{ + Attributes: map[string]*SchemaAttribute{}, + BlockTypes: map[string]*SchemaBlockType{ + "child": { + MinItems: 1, + Block: &SchemaBlock{ + Attributes: map[string]*SchemaAttribute{ + "required_attr": {Required: true}, + }, + BlockTypes: map[string]*SchemaBlockType{}, + }, + }, + }, + } + + bd := BlockData{ + Properties: map[string]bool{}, + StaticBlocks: map[string][]*ParsedBlock{}, + DynamicBlocks: map[string]*ParsedBlock{ + "child": { + Data: BlockData{ + Properties: map[string]bool{}, // missing required_attr + StaticBlocks: map[string][]*ParsedBlock{}, + DynamicBlocks: map[string]*ParsedBlock{}, + IgnoreChanges: nil, + }, + }, + }, + } + + // Add multiple static children to exercise indexed paths + bd.StaticBlocks["child"] = []*ParsedBlock{ + {Data: BlockData{Properties: map[string]bool{}}}, + {Data: BlockData{Properties: map[string]bool{}}}, + } + + var findings []ValidationFinding + bd.validateBlocks("azurerm_virtual_network", "root", schema, nil, &findings) + + if len(findings) != 3 { + t.Fatalf("expected findings for each static and dynamic child, got %d", len(findings)) + } + + paths := map[string]int{} + for _, f := range findings { + paths[f.Path]++ + if f.Name != "required_attr" { + t.Fatalf("unexpected finding: %+v", f) + } + } + + if paths["root.child"] != 1 || paths["root.child[0]"] != 1 || paths["root.child[1]"] != 1 { + t.Fatalf("expected indexed paths for static blocks and plain path for dynamic, got %v", paths) + } +} + +func TestValidateTerraformSchemaSuccess(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "main.tf"), "# stub") + + parser := &stubParser{ + providerSource: "registry.terraform.io/hashicorp/azurerm", + resources: []ParsedResource{ + { + Type: "azurerm_virtual_network", + Name: "test", + Data: BlockData{ + Properties: map[string]bool{"location": true}, + StaticBlocks: map[string][]*ParsedBlock{ + "subnet": {{ + Data: BlockData{ + Properties: map[string]bool{"name": true}, + StaticBlocks: map[string][]*ParsedBlock{}, + DynamicBlocks: map[string]*ParsedBlock{}, + IgnoreChanges: nil, + }, + }}, + }, + DynamicBlocks: map[string]*ParsedBlock{}, + IgnoreChanges: nil, + }, + }, + }, + } + + runner := &stubRunner{ + schema: &TerraformSchema{ + ProviderSchemas: map[string]*ProviderSchema{ + "registry.terraform.io/hashicorp/azurerm": { + ResourceSchemas: map[string]*ResourceSchema{ + "azurerm_virtual_network": { + Block: &SchemaBlock{ + Attributes: map[string]*SchemaAttribute{ + "location": {Required: true}, + }, + BlockTypes: map[string]*SchemaBlockType{ + "subnet": { + MinItems: 1, + Block: &SchemaBlock{ + Attributes: map[string]*SchemaAttribute{ + "name": {Required: true}, + }, + BlockTypes: map[string]*SchemaBlockType{}, + }, + }, + }, + }, + }, + }, + DataSourceSchemas: map[string]*ResourceSchema{}, + }, + }, + }, + } + + findings, err := ValidateTerraformSchema(&SimpleLogger{}, dir, "", parser, runner) + if err != nil { + t.Fatalf("ValidateTerraformSchema returned error: %v", err) + } + if len(findings) != 0 { + t.Fatalf("expected no findings, got %d", len(findings)) + } +} + +func TestValidateTerraformSchemaInitError(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "main.tf"), "# stub") + + parser := &stubParser{ + providerSource: "registry.terraform.io/hashicorp/azurerm", + resources: nil, + } + + runner := &stubRunner{schema: &TerraformSchema{}} + runnerFail := &failingRunner{} + + if _, err := ValidateTerraformSchema(&SimpleLogger{}, dir, "", parser, runnerFail); err == nil { + t.Fatalf("expected init error") + } + + // Ensure success path still works with stub runner + if _, err := ValidateTerraformSchema(&SimpleLogger{}, dir, "", parser, runner); err != nil { + t.Fatalf("unexpected error with stub runner: %v", err) + } +} + +func TestWalkTerraformFiles(t *testing.T) { + dir := t.TempDir() + + tfA := filepath.Join(dir, "a.tf") + tfB := filepath.Join(dir, "b.tf") + other := filepath.Join(dir, "notes.txt") + + writeFile(t, tfA, "resource \"x\" \"a\" {}") + writeFile(t, tfB, "resource \"x\" \"b\" {}") + writeFile(t, other, "# not terraform") + + files, err := walkTerraformFiles(dir) + if err != nil { + t.Fatalf("walkTerraformFiles returned error: %v", err) + } + + want := []string{tfA, tfB} + if len(files) != len(want) { + t.Fatalf("walkTerraformFiles returned %d files, want %d", len(files), len(want)) + } + + for i := range want { + if files[i] != want[i] { + t.Fatalf("walkTerraformFiles order mismatch at %d: got %s want %s", i, files[i], want[i]) + } + } +} + +func TestWalkTerraformFilesMissingDir(t *testing.T) { + _, err := walkTerraformFiles(filepath.Join(t.TempDir(), "does-not-exist")) + if err == nil { + t.Fatalf("expected error for missing directory") + } +} + +func TestValidateProjectWithSubmodules(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "main.tf"), "# root") + + modulesDir := filepath.Join(root, "modules", "network") + if err := os.MkdirAll(modulesDir, 0o755); err != nil { + t.Fatalf("failed to create module dir: %v", err) + } + writeFile(t, filepath.Join(modulesDir, "main.tf"), "# module") + + parser := &stubParser{ + providerSource: "registry.terraform.io/hashicorp/azurerm", + resources: []ParsedResource{ + { + Type: "azurerm_virtual_network", + Name: "test", + Data: BlockData{ + Properties: map[string]bool{}, // missing required attribute to trigger finding + StaticBlocks: make(map[string][]*ParsedBlock), + DynamicBlocks: make(map[string]*ParsedBlock), + IgnoreChanges: nil, + }, + }, + }, + } + + runner := &stubRunner{ + schema: &TerraformSchema{ + ProviderSchemas: map[string]*ProviderSchema{ + "registry.terraform.io/hashicorp/azurerm": { + ResourceSchemas: map[string]*ResourceSchema{ + "azurerm_virtual_network": { + Block: &SchemaBlock{ + Attributes: map[string]*SchemaAttribute{ + "location": {Required: true}, + }, + BlockTypes: map[string]*SchemaBlockType{}, + }, + }, + }, + DataSourceSchemas: map[string]*ResourceSchema{}, + }, + }, + }, + } + + opts := &SchemaValidatorOptions{ + TerraformRoot: root, + Logger: &SimpleLogger{}, + Silent: true, + Parser: parser, + TerraformRunner: runner, + } + + findings, err := validateProject(opts) + if err != nil { + t.Fatalf("validateProject returned error: %v", err) + } + + if len(findings) != 2 { + t.Fatalf("expected findings for root and submodule, got %d", len(findings)) + } + + submodules := map[string]int{} + for _, f := range findings { + submodules[f.SubmoduleName]++ + if f.Name != "location" || !f.Required { + t.Fatalf("unexpected finding: %+v", f) + } + } + + if submodules[""] != 1 || submodules["network"] != 1 { + t.Fatalf("findings should include one root and one network submodule entry, got %v", submodules) + } + + if !runner.initCalled(root) || !runner.initCalled(modulesDir) { + t.Fatalf("runner.Init should be invoked for root and submodule, got %v", runner.inited) + } +} + +type stubParser struct { + providerSource string + resources []ParsedResource +} + +func (p *stubParser) ParseProviderRequirements(_ context.Context, _ string) (map[string]ProviderConfig, error) { + return map[string]ProviderConfig{ + "azurerm": {Source: p.providerSource}, + }, nil +} + +func (p *stubParser) ParseMainFile(ctx context.Context, filename string) ([]ParsedResource, []ParsedDataSource, error) { + return p.ParseTerraformFiles(ctx, []string{filename}) +} + +func (p *stubParser) ParseTerraformFiles(_ context.Context, _ []string) ([]ParsedResource, []ParsedDataSource, error) { + return p.resources, nil, nil +} + +type stubRunner struct { + schema *TerraformSchema + inited map[string]bool +} + +func (r *stubRunner) Init(_ context.Context, dir string) error { + if r.inited == nil { + r.inited = make(map[string]bool) + } + r.inited[dir] = true + return nil +} + +func (r *stubRunner) GetSchema(_ context.Context, _ string) (*TerraformSchema, error) { + return r.schema, nil +} + +func (r *stubRunner) initCalled(dir string) bool { + return r.inited != nil && r.inited[dir] +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write file %s: %v", path, err) + } +} + +func TestNewSchemaValidator(t *testing.T) { + logger := &SimpleLogger{} + validator := NewSchemaValidator(logger) + + if validator == nil { + t.Fatal("NewSchemaValidator() should not return nil") + } + + if validator.logger != logger { + t.Error("NewSchemaValidator() should set the logger") + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} From 68380ba804e35513de827101eecee3b48e87b8af Mon Sep 17 00:00:00 2001 From: dkooll Date: Sat, 27 Dec 2025 16:12:20 +0100 Subject: [PATCH 2/2] feat: add tests --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 22914cc..767d9a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: GOCACHE: ${{ runner.temp }}/go-cache run: | go clean -testcache - go test -count=1 -v -coverprofile=coverage.out ./... + go test -count=1 -v -coverprofile=coverage.out $(go list ./... | grep -v '/examples/') - name: Display coverage summary run: go tool cover -func=coverage.out