diff --git a/app/cli/internal/policydevel/lint_test.go b/app/cli/internal/policydevel/lint_test.go new file mode 100644 index 000000000..0bac2c6bb --- /dev/null +++ b/app/cli/internal/policydevel/lint_test.go @@ -0,0 +1,353 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policydevel + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/styrainc/regal/pkg/report" +) + +func TestLookup(t *testing.T) { + tempDir := t.TempDir() + + t.Run("non-existent file", func(t *testing.T) { + policy, err := Lookup(filepath.Join(tempDir, "nonexistent.yaml"), "", false) + assert.Error(t, err) + assert.Nil(t, policy) + assert.Contains(t, err.Error(), "unrecognized scheme") + }) + + t.Run("directory instead of file", func(t *testing.T) { + policy, err := Lookup(tempDir, "", false) + assert.Error(t, err) + assert.Nil(t, policy) + assert.Contains(t, err.Error(), "expected a file but got a directory") + }) + + t.Run("valid yaml file", func(t *testing.T) { + policy, err := Lookup("testdata/embedded-policy.yaml", "", false) + require.NoError(t, err) + assert.NotNil(t, policy) + assert.Contains(t, policy.Path, "testdata/embedded-policy.yaml") + assert.Len(t, policy.YAMLFiles, 1) + assert.Len(t, policy.RegoFiles, 0) + }) + + t.Run("valid rego file", func(t *testing.T) { + policy, err := Lookup("testdata/valid.rego", "", false) + require.NoError(t, err) + assert.NotNil(t, policy) + assert.Contains(t, policy.Path, "testdata/valid.rego") + assert.Len(t, policy.YAMLFiles, 0) + assert.Len(t, policy.RegoFiles, 1) + }) + + t.Run("unsupported file extension", func(t *testing.T) { + txtFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(txtFile, []byte("some content"), 0600) + require.NoError(t, err) + + policy, err := Lookup(txtFile, "", false) + assert.Error(t, err) + assert.Nil(t, policy) + assert.Contains(t, err.Error(), "unsupported file extension .txt") + }) + + t.Run("yaml with referenced rego file", func(t *testing.T) { + policy, err := Lookup("testdata/policy.yaml", "", false) + require.NoError(t, err) + assert.NotNil(t, policy) + assert.Len(t, policy.YAMLFiles, 1) + assert.Len(t, policy.RegoFiles, 1) + }) +} + +func TestPolicyToLint_processFile(t *testing.T) { + tempDir := t.TempDir() + policy := &PolicyToLint{} + + t.Run("process yaml file", func(t *testing.T) { + content := "test: yaml" + yamlFile := filepath.Join(tempDir, "test.yaml") + err := os.WriteFile(yamlFile, []byte(content), 0600) + require.NoError(t, err) + + err = policy.processFile(yamlFile) + require.NoError(t, err) + assert.Len(t, policy.YAMLFiles, 1) + assert.Equal(t, yamlFile, policy.YAMLFiles[0].Path) + assert.Equal(t, []byte(content), policy.YAMLFiles[0].Content) + }) + + t.Run("process rego file", func(t *testing.T) { + content := "package main" + regoFile := filepath.Join(tempDir, "test.rego") + err := os.WriteFile(regoFile, []byte(content), 0600) + require.NoError(t, err) + + err = policy.processFile(regoFile) + require.NoError(t, err) + assert.Len(t, policy.RegoFiles, 1) + assert.Equal(t, regoFile, policy.RegoFiles[0].Path) + assert.Equal(t, []byte(content), policy.RegoFiles[0].Content) + }) + + t.Run("unsupported file extension", func(t *testing.T) { + txtFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(txtFile, []byte("content"), 0600) + require.NoError(t, err) + + err = policy.processFile(txtFile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported file extension .txt") + }) +} + +func TestPolicyToLint_checkResultStructure(t *testing.T) { + t.Run("valid result structure", func(t *testing.T) { + policy := &PolicyToLint{} + content, err := os.ReadFile("testdata/valid.rego") + require.NoError(t, err) + policy.checkResultStructure(string(content), "test.rego", []string{"violations", "skip_reason", "skipped"}) + assert.False(t, policy.HasErrors()) + }) + + t.Run("missing result literal", func(t *testing.T) { + policy := &PolicyToLint{} + content := `package main + +output := { + "violations": [] +}` + policy.checkResultStructure(content, "test.rego", []string{"violations"}) + assert.True(t, policy.HasErrors()) + assert.Contains(t, policy.Errors[0].Message, "no result literal found") + }) + + t.Run("missing required keys", func(t *testing.T) { + policy := &PolicyToLint{} + content, err := os.ReadFile("testdata/missing-keys.rego") + require.NoError(t, err) + policy.checkResultStructure(string(content), "test.rego", []string{"violations", "skip_reason", "skipped"}) + assert.True(t, policy.HasErrors()) + assert.Len(t, policy.Errors, 2) + assert.Contains(t, policy.Errors[0].Message, `missing "skip_reason" key`) + assert.Contains(t, policy.Errors[1].Message, `missing "skipped" key`) + }) +} + +func TestPolicyToLint_formatViolationError(t *testing.T) { + policy := &PolicyToLint{} + + testCases := []struct { + name string + violation report.Violation + regoRuleMap map[int]string + expectedText string + }{ + { + name: "violation with rule name", + violation: report.Violation{ + Description: "Max rule length exceeded", + Location: report.Location{ + Row: 5, + }, + RelatedResources: []report.RelatedResource{ + {Reference: "https://docs.styra.com/regal/rules/style/rule-length"}, + }, + }, + regoRuleMap: map[int]string{5: "my_rule"}, + expectedText: "[my_rule]: Max rule length exceeded - https://docs.styra.com/regal/rules/style/rule-length", + }, + { + name: "violation without rule name", + violation: report.Violation{ + Description: "General error", + Location: report.Location{ + Row: 10, + }, + RelatedResources: []report.RelatedResource{ + {Reference: "https://example.com"}, + }, + }, + regoRuleMap: map[int]string{}, + expectedText: ": General error - https://example.com", + }, + { + name: "violation with multiple resources", + violation: report.Violation{ + Description: "Multiple issues found", + Location: report.Location{ + Row: 3, + }, + RelatedResources: []report.RelatedResource{ + {Reference: "https://link1.com"}, + {Reference: "https://link2.com"}, + }, + }, + regoRuleMap: map[int]string{3: "test_rule"}, + expectedText: "[test_rule]: Multiple issues found - https://link1.com, https://link2.com", + }, + { + name: "violation with opa fmt reference", + violation: report.Violation{ + Description: "Use `opa fmt` to format", + Location: report.Location{ + Row: 1, + }, + RelatedResources: []report.RelatedResource{ + {Reference: "https://example.com"}, + }, + }, + regoRuleMap: map[int]string{1: "format_rule"}, + expectedText: "[format_rule]: Use `--format` to format - https://example.com", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := policy.formatViolationError(tc.violation, tc.regoRuleMap) + assert.Equal(t, tc.expectedText, result) + }) + } +} + +func TestPolicyToLint_buildRegoRuleMap(t *testing.T) { + policy := &PolicyToLint{} + + testCases := []struct { + name string + regoFile string + expected map[int]string + }{ + { + name: "single rule", + regoFile: "testdata/valid.rego", + expected: map[int]string{3: "result"}, + }, + { + name: "multiple rules", + regoFile: "testdata/multiple-rules.rego", + expected: map[int]string{ + 3: "allow", + 5: "deny", + 7: "result", + }, + }, + { + name: "empty rego", + regoFile: "", + expected: map[int]string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var content string + if tc.regoFile != "" { + contentBytes, err := os.ReadFile(tc.regoFile) + require.NoError(t, err) + content = string(contentBytes) + } else { + content = "invalid rego syntax {" + } + result := policy.buildRegoRuleMap(content) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestPolicyToLint_applyOPAFmt(t *testing.T) { + t.Run("format valid rego", func(t *testing.T) { + policy := &PolicyToLint{} + content, err := os.ReadFile("testdata/unformatted.rego") + require.NoError(t, err) + + result := policy.applyOPAFmt(string(content), "test.rego") + assert.Contains(t, result, "result := {") + assert.False(t, policy.HasErrors()) + }) + + t.Run("format invalid rego", func(t *testing.T) { + policy := &PolicyToLint{} + content := `invalid rego {` + result := policy.applyOPAFmt(content, "test.rego") + assert.Equal(t, content, result) + assert.True(t, policy.HasErrors()) + assert.Contains(t, policy.Errors[0].Message, "auto-formatting failed") + }) +} + +func TestPolicyToLint_Validate(t *testing.T) { + tempDir := t.TempDir() + + t.Run("validate rego files", func(t *testing.T) { + content, err := os.ReadFile("testdata/valid.rego") + require.NoError(t, err) + + regoFile := filepath.Join(tempDir, "test.rego") + err = os.WriteFile(regoFile, content, 0600) + require.NoError(t, err) + + policy := &PolicyToLint{ + RegoFiles: []*File{ + { + Path: regoFile, + Content: content, + }, + }, + } + + policy.Validate() + assert.False(t, policy.HasErrors()) + }) + + t.Run("validate and format rego files", func(t *testing.T) { + content, err := os.ReadFile("testdata/unformatted.rego") + require.NoError(t, err) + + regoFile := filepath.Join(tempDir, "format_test.rego") + err = os.WriteFile(regoFile, content, 0600) + require.NoError(t, err) + + policy := &PolicyToLint{ + Format: true, + RegoFiles: []*File{ + { + Path: regoFile, + Content: content, + }, + }, + } + + policy.Validate() + + formatted, err := os.ReadFile(regoFile) + require.NoError(t, err) + formattedStr := string(formatted) + + expected, err := os.ReadFile("testdata/valid.rego") + require.NoError(t, err) + expectedStr := string(expected) + + assert.Equal(t, expectedStr, formattedStr) + }) +} diff --git a/app/cli/internal/policydevel/testdata/embedded-policy.yaml b/app/cli/internal/policydevel/testdata/embedded-policy.yaml new file mode 100644 index 000000000..1c4602347 --- /dev/null +++ b/app/cli/internal/policydevel/testdata/embedded-policy.yaml @@ -0,0 +1,16 @@ +apiVersion: workflowcontract.chainloop.dev/v1 +kind: Policy +metadata: + name: test-policy + description: Test validation policy +spec: + policies: + - embedded: | + package main + + result := { + "violations": [], + "skip_reason": "", + "skipped": false + } + kind: ATTESTATION \ No newline at end of file diff --git a/app/cli/internal/policydevel/testdata/missing-keys.rego b/app/cli/internal/policydevel/testdata/missing-keys.rego new file mode 100644 index 000000000..a4f5d6545 --- /dev/null +++ b/app/cli/internal/policydevel/testdata/missing-keys.rego @@ -0,0 +1,5 @@ +package main + +result := { + "violations": [] +} \ No newline at end of file diff --git a/app/cli/internal/policydevel/testdata/multiple-rules.rego b/app/cli/internal/policydevel/testdata/multiple-rules.rego new file mode 100644 index 000000000..74201b6b7 --- /dev/null +++ b/app/cli/internal/policydevel/testdata/multiple-rules.rego @@ -0,0 +1,9 @@ +package main + +allow := true + +deny := false + +result := { + "violations": [] +} \ No newline at end of file diff --git a/app/cli/internal/policydevel/testdata/policy.yaml b/app/cli/internal/policydevel/testdata/policy.yaml new file mode 100644 index 000000000..7c3e9a983 --- /dev/null +++ b/app/cli/internal/policydevel/testdata/policy.yaml @@ -0,0 +1,9 @@ +apiVersion: workflowcontract.chainloop.dev/v1 +kind: Policy +metadata: + name: test-policy + description: Test policy with referenced rego file +spec: + policies: + - path: valid.rego + kind: ATTESTATION \ No newline at end of file diff --git a/app/cli/internal/policydevel/testdata/unformatted.rego b/app/cli/internal/policydevel/testdata/unformatted.rego new file mode 100644 index 000000000..16018686b --- /dev/null +++ b/app/cli/internal/policydevel/testdata/unformatted.rego @@ -0,0 +1,7 @@ +package main + +result := { + "violations": [], + "skip_reason": "", + "skipped": false +} \ No newline at end of file diff --git a/app/cli/internal/policydevel/testdata/valid.rego b/app/cli/internal/policydevel/testdata/valid.rego new file mode 100644 index 000000000..a8bcca952 --- /dev/null +++ b/app/cli/internal/policydevel/testdata/valid.rego @@ -0,0 +1,7 @@ +package main + +result := { + "violations": [], + "skip_reason": "", + "skipped": false, +}