diff --git a/README.md b/README.md index 9375efd..1b11719 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,123 @@ You will see a summary of the policy evaluations in the terminal and a report in Do you want to create your own policies? Please refer to our guide [Create new policy](./docs/create-new-policy.md) +## Subcommands Reference + +### export-model + +Export Mendix model to yaml files. The output is a text representation of the model. It is a one-way conversion that aims to keep the semantics yet readable for humans and computers. + +**Usage:** +```bash +mxlint-cli export-model [flags] +``` + +**Flags:** +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--input` | `-i` | `.` | Path to directory or mpr file to export. If it's a directory, all mpr files will be exported | +| `--output` | `-o` | `modelsource` | Path to directory to write the yaml files. If it doesn't exist, it will be created | +| `--mode` | `-m` | `basic` | Export mode. Valid options: `basic`, `advanced` | +| `--filter` | `-f` | | Regex pattern to filter units by name. Only units with names matching the pattern will be exported | +| `--raw` | | `false` | If set, the output yaml will include all attributes as they are in the model | +| `--appstore` | | `false` | If set, appstore modules will be included in the output | +| `--verbose` | | `false` | Turn on for debug logs | + +--- + +### lint + +Evaluate Mendix model against rules. Requires the model to be exported first. The model is evaluated against a set of rules defined in OPA Rego files or JavaScript. The output is a list of checked rules and their outcome. + +**Usage:** +```bash +mxlint-cli lint [flags] +``` + +**Flags:** +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--rules` | `-r` | `.mendix-cache/rules` | Path to directory with rules | +| `--modelsource` | `-m` | `modelsource` | Path to directory with exported model | +| `--xunit-report` | `-x` | | Path to output file for xunit report. If not provided, no xunit report will be generated | +| `--json-file` | `-j` | | Path to output file for JSON report. If not provided, no JSON file will be generated | +| `--ignore-noqa` | | `false` | Ignore noqa directives in documents | +| `--no-cache` | | `false` | Disable caching of lint results. By default, results are cached and reused if rules and model files haven't changed | +| `--verbose` | | `false` | Turn on for debug logs | + +--- + +### serve + +Run a server that exports model and lints whenever the input MPR file changes. Works in standalone mode and via integration with the Mendix Studio Pro extension. + +**Usage:** +```bash +mxlint-cli serve [flags] +``` + +**Flags:** +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--input` | `-i` | `.` | Path to directory or mpr file to export. If it's a directory, all mpr files will be exported | +| `--output` | `-o` | `modelsource` | Path to directory to write the yaml files. If it doesn't exist, it will be created | +| `--mode` | `-m` | `basic` | Export mode. Valid options: `basic`, `advanced` | +| `--rules` | `-r` | `.mendix-cache/rules` | Path to directory with rules | +| `--port` | `-p` | `8082` | Port to run the server on | +| `--debounce` | `-d` | `500` | Debounce time in milliseconds for file change events | +| `--verbose` | | `false` | Turn on for debug logs | + +--- + +### test-rules + +Ensure rules are working as expected against predefined test cases. When you are developing a new rule, you can use this command to ensure it works as expected. + +**Usage:** +```bash +mxlint-cli test-rules [flags] +``` + +**Flags:** +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--rules` | `-r` | `.mendix-cache/rules` | Path to directory with rules | +| `--verbose` | | `false` | Turn on for debug logs | + +--- + +### cache-clear + +Clear the lint results cache. Removes all cached lint results. The cache is used to speed up repeated linting operations when rules and model files haven't changed. + +**Usage:** +```bash +mxlint-cli cache-clear [flags] +``` + +**Flags:** +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--verbose` | | `false` | Turn on for debug logs | + +--- + +### cache-stats + +Show cache statistics. Displays information about the cached lint results, including number of entries and total size. + +**Usage:** +```bash +mxlint-cli cache-stats [flags] +``` + +**Flags:** +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--verbose` | | `false` | Turn on for debug logs | + +--- + ## export-model Mendix models are stored in a binary file with `.mpr` extension. This project exports Mendix model to a human readable format, such as Yaml. This enables developers to use traditional code analysis tools on Mendix models. Think of quality checks like linting, code formatting, etc. diff --git a/lint/lint.go b/lint/lint.go index 7961a08..b6b9ea4 100644 --- a/lint/lint.go +++ b/lint/lint.go @@ -30,7 +30,7 @@ func printTestsuite(ts Testsuite) { // EvalAllWithResults evaluates all rules and returns the results // This is similar to EvalAll but returns the results instead of just printing them -func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool) (interface{}, error) { +func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool, useCache bool) (interface{}, error) { rules, err := ReadRulesMetadata(rulesPath) if err != nil { return nil, err @@ -54,7 +54,7 @@ func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport st go func(index int, r Rule) { defer wg.Done() - testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa) + testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache) if err != nil { errChan <- err return @@ -138,7 +138,7 @@ func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport st return testsuitesContainer, nil } -func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool) error { +func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool, useCache bool) error { rules, err := ReadRulesMetadata(rulesPath) if err != nil { return err @@ -162,7 +162,7 @@ func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonF go func(index int, r Rule) { defer wg.Done() - testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa) + testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache) if err != nil { errChan <- err return @@ -259,7 +259,7 @@ func countTotalTestcases(testsuites []Testsuite) int { return count } -func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsuite, error) { +func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool, useCache bool) (*Testsuite, error) { log.Debugf("evaluating rule %s", rule.Path) @@ -276,25 +276,25 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsui for _, inputFile := range inputFiles { - // Try to load from cache first (but skip cache if ignoreNoqa is true) + // Try to load from cache first (but skip cache if ignoreNoqa is true or useCache is false) cacheKey, err := createCacheKey(rule.Path, inputFile) if err != nil { log.Debugf("Error creating cache key: %v", err) - } else if !ignoreNoqa { + } else if useCache && !ignoreNoqa { cachedTestcase, found := loadCachedTestcase(*cacheKey) if found { testcase = cachedTestcase log.Debugf("Using cached result for %s", inputFile) } else { // Cache miss - evaluate and save to cache - testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa) + testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa, modelSourcePath, useCache) if err != nil { return nil, err } } } else { - // ignoreNoqa is true, skip cache and evaluate directly - testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa) + // useCache is false or ignoreNoqa is true, skip cache and evaluate directly + testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa, modelSourcePath, useCache) if err != nil { return nil, err } @@ -305,9 +305,9 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsui if rule.Language == LanguageRego { testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile, rule.RuleNumber, ignoreNoqa) } else if rule.Language == LanguageJavascript { - testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa) + testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa, modelSourcePath) } else if rule.Language == LanguageTypescript { - testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa) + testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa, modelSourcePath) } if err != nil { return nil, err @@ -340,25 +340,25 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsui } // evalTestcaseWithCaching evaluates a testcase and saves the result to cache -func evalTestcaseWithCaching(rule Rule, queryString string, inputFile string, cacheKey *CacheKey, ignoreNoqa bool) (*Testcase, error) { +func evalTestcaseWithCaching(rule Rule, queryString string, inputFile string, cacheKey *CacheKey, ignoreNoqa bool, modelSourcePath string, useCache bool) (*Testcase, error) { var testcase *Testcase var err error if rule.Language == LanguageRego { testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile, rule.RuleNumber, ignoreNoqa) } else if rule.Language == LanguageJavascript { - testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa) + testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa, modelSourcePath) } else if rule.Language == LanguageTypescript { - testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa) + testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa, modelSourcePath) } if err != nil { return nil, err } - // Only save to cache when ignoreNoqa is false + // Only save to cache when useCache is true and ignoreNoqa is false // When ignoreNoqa is true, the result might differ from the normal behavior - if !ignoreNoqa { + if useCache && !ignoreNoqa { if cacheErr := saveCachedTestcase(*cacheKey, testcase); cacheErr != nil { log.Debugf("Error saving to cache: %v", cacheErr) // Don't fail the evaluation if cache save fails diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index b0fce4b..0d94c30 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -12,8 +12,11 @@ import ( ) // resolvePath resolves the given path relative to the working directory and validates -// that it stays within the working directory. Returns the absolute path or an error. -func resolvePath(pathArg string, workingDirectory string) (string, error) { +// that it stays within the allowed root. Returns the absolute path or an error. +func resolvePath(pathArg string, workingDirectory string, allowedRoot string) (string, error) { + if allowedRoot == "" { + allowedRoot = workingDirectory + } // Resolve the path relative to working directory var fullPath string if filepath.IsAbs(pathArg) { @@ -29,15 +32,19 @@ func resolvePath(pathArg string, workingDirectory string) (string, error) { } absFullPath = filepath.Clean(absFullPath) - absWorkingDir, err := filepath.Abs(workingDirectory) + absAllowedRoot, err := filepath.Abs(allowedRoot) if err != nil { return "", fmt.Errorf("failed to resolve working directory: %w", err) } - absWorkingDir = filepath.Clean(absWorkingDir) + absAllowedRoot = filepath.Clean(absAllowedRoot) - // Check that the resolved path is within the working directory - if !strings.HasPrefix(absFullPath, absWorkingDir+string(filepath.Separator)) && absFullPath != absWorkingDir { - return "", fmt.Errorf("path %q is outside working directory %q", pathArg, workingDirectory) + // Check that the resolved path is within the allowed root + relPath, err := filepath.Rel(absAllowedRoot, absFullPath) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path %q is outside modelsource root %q", absFullPath, absAllowedRoot) } return absFullPath, nil @@ -51,7 +58,7 @@ func resolvePath(pathArg string, workingDirectory string) (string, error) { // The path is resolved relative to the workingDirectory. // - mxlint.io.isdir(path): Returns true if the path is a directory, false otherwise. // The path is resolved relative to the workingDirectory. -func setupJavascriptVM(workingDirectory string) *sobek.Runtime { +func setupJavascriptVM(workingDirectory string, allowedRoot string) *sobek.Runtime { vm := sobek.New() // Create the mxlint object @@ -69,7 +76,7 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { } filepathArg := call.Argument(0).String() - absPath, err := resolvePath(filepathArg, workingDirectory) + absPath, err := resolvePath(filepathArg, workingDirectory, allowedRoot) if err != nil { panic(vm.NewGoError(fmt.Errorf("mxlint.io.readfile: %w", err))) } @@ -88,7 +95,7 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { } dirpathArg := call.Argument(0).String() - absPath, err := resolvePath(dirpathArg, workingDirectory) + absPath, err := resolvePath(dirpathArg, workingDirectory, allowedRoot) if err != nil { panic(vm.NewGoError(fmt.Errorf("mxlint.io.listdir: %w", err))) } @@ -114,7 +121,7 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { } pathArg := call.Argument(0).String() - absPath, err := resolvePath(pathArg, workingDirectory) + absPath, err := resolvePath(pathArg, workingDirectory, allowedRoot) if err != nil { panic(vm.NewGoError(fmt.Errorf("mxlint.io.isdir: %w", err))) } @@ -133,7 +140,7 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { return vm } -func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool) (*Testcase, error) { +func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool, modelSourcePath string) (*Testcase, error) { ruleContent, _ := os.ReadFile(rulePath) log.Debugf("js file: \n%s", ruleContent) @@ -171,9 +178,13 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber s startTime := time.Now() - // Use the directory containing the input file as the working directory - workingDirectory := filepath.Dir(inputFilePath) - vm := setupJavascriptVM(workingDirectory) + // Use the modelsource path as the working directory, falling back to input file's directory + workingDirectory := modelSourcePath + if workingDirectory == "" { + workingDirectory = filepath.Dir(inputFilePath) + } + allowedRoot := resolveAllowedRoot(modelSourcePath) + vm := setupJavascriptVM(workingDirectory, allowedRoot) _, err = vm.RunString(string(ruleContent)) if err != nil { panic(err) diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go index 15094d7..1ba8e5c 100644 --- a/lint/lint_javascript_test.go +++ b/lint/lint_javascript_test.go @@ -19,7 +19,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { } t.Run("read file with relative path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.readfile("test.txt")` result, err := vm.RunString(script) @@ -33,7 +33,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("read file with absolute path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.readfile("` + testFilePath + `")` result, err := vm.RunString(script) @@ -47,7 +47,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("read nonexistent file throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -68,14 +68,14 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("path traversal with .. is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.readfile("../../../etc/passwd"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -89,14 +89,14 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("absolute path outside working directory is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.readfile("/etc/passwd"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -110,7 +110,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("readfile without argument throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -145,7 +145,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { t.Fatalf("Failed to create subfile: %v", err) } - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.readfile("subdir/subfile.txt")` result, err := vm.RunString(script) @@ -182,7 +182,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { } t.Run("list directory with relative path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `JSON.stringify(mxlint.io.listdir(".").sort())` result, err := vm.RunString(script) @@ -197,7 +197,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("list directory with absolute path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `JSON.stringify(mxlint.io.listdir("` + tempDir + `").sort())` result, err := vm.RunString(script) @@ -212,7 +212,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("list subdirectory", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `JSON.stringify(mxlint.io.listdir("subdir"))` result, err := vm.RunString(script) @@ -227,7 +227,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("list nonexistent directory throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -248,14 +248,14 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("path traversal with .. is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.listdir("../../../etc"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -269,14 +269,14 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("absolute path outside working directory is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.listdir("/etc"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -290,7 +290,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("listdir without argument throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -318,7 +318,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { t.Fatalf("Failed to create empty directory: %v", err) } - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `JSON.stringify(mxlint.io.listdir("empty"))` result, err := vm.RunString(script) @@ -333,7 +333,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("listdir on file throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -369,7 +369,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { } t.Run("isdir returns true for directory with relative path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("subdir")` result, err := vm.RunString(script) @@ -383,7 +383,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir returns true for directory with absolute path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("` + filepath.Join(tempDir, "subdir") + `")` result, err := vm.RunString(script) @@ -397,7 +397,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir returns false for file", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("file.txt")` result, err := vm.RunString(script) @@ -411,7 +411,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir returns false for nonexistent path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("nonexistent")` result, err := vm.RunString(script) @@ -425,7 +425,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir returns true for current directory", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir(".")` result, err := vm.RunString(script) @@ -439,14 +439,14 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("path traversal with .. is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.isdir("../../../etc"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -460,14 +460,14 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("absolute path outside working directory is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.isdir("/etc"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -481,7 +481,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir without argument throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -509,7 +509,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { t.Fatalf("Failed to create nested directory: %v", err) } - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("subdir/nested")` result, err := vm.RunString(script) @@ -524,7 +524,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { } func TestMxlintObjectAvailable(t *testing.T) { - vm := setupJavascriptVM(".") + vm := setupJavascriptVM(".", ".") // Check that mxlint object is available script := `typeof mxlint` diff --git a/lint/lint_rego_test.go b/lint/lint_rego_test.go new file mode 100644 index 0000000..60080ae --- /dev/null +++ b/lint/lint_rego_test.go @@ -0,0 +1,562 @@ +package lint + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseRuleMetadata_Rego(t *testing.T) { + tempDir := t.TempDir() + + t.Run("parse valid metadata", func(t *testing.T) { + regoContent := `# METADATA +# scope: package +# title: Test Rego Rule +# description: A test rule for validation +# authors: +# - Test Author +# custom: +# category: Testing +# rulename: TestRegoRule +# severity: HIGH +# rulenumber: "001_0001" +# remediation: Fix the issue +# input: .*\.yaml +package test.rule + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + not input.Name + error := "Name is required" +} +` + regoPath := filepath.Join(tempDir, "valid_metadata.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Rego(regoPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.Title != "Test Rego Rule" { + t.Errorf("Expected title 'Test Rego Rule', got %q", rule.Title) + } + if rule.Description != "A test rule for validation" { + t.Errorf("Expected description 'A test rule for validation', got %q", rule.Description) + } + if rule.Category != "Testing" { + t.Errorf("Expected category 'Testing', got %q", rule.Category) + } + if rule.Severity != "HIGH" { + t.Errorf("Expected severity 'HIGH', got %q", rule.Severity) + } + if rule.RuleNumber != "001_0001" { + t.Errorf("Expected rulenumber '001_0001', got %q", rule.RuleNumber) + } + if rule.Remediation != "Fix the issue" { + t.Errorf("Expected remediation 'Fix the issue', got %q", rule.Remediation) + } + if rule.RuleName != "TestRegoRule" { + t.Errorf("Expected rulename 'TestRegoRule', got %q", rule.RuleName) + } + if rule.Pattern != ".*\\.yaml" { + t.Errorf("Expected pattern '.*\\.yaml', got %q", rule.Pattern) + } + if rule.PackageName != "test.rule" { + t.Errorf("Expected package name 'test.rule', got %q", rule.PackageName) + } + if rule.Language != LanguageRego { + t.Errorf("Expected language 'rego', got %q", rule.Language) + } + if rule.Path != regoPath { + t.Errorf("Expected path %q, got %q", regoPath, rule.Path) + } + }) + + t.Run("parse metadata with unquoted rulenumber", func(t *testing.T) { + regoContent := `# METADATA +# scope: package +# title: Unquoted Rulenumber Test +# custom: +# rulenumber: 002_0002 +package test.unquoted + +default allow := true +` + regoPath := filepath.Join(tempDir, "unquoted_rulenumber.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Rego(regoPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.Title != "Unquoted Rulenumber Test" { + t.Errorf("Expected title 'Unquoted Rulenumber Test', got %q", rule.Title) + } + // The rulenumber should be preserved as a string + if rule.RuleNumber != "002_0002" { + t.Errorf("Expected rulenumber '002_0002', got %q", rule.RuleNumber) + } + }) + + t.Run("parse without metadata", func(t *testing.T) { + regoContent := `package test.no_metadata + +default allow := true +` + regoPath := filepath.Join(tempDir, "no_metadata.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Rego(regoPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.PackageName != "test.no_metadata" { + t.Errorf("Expected package name 'test.no_metadata', got %q", rule.PackageName) + } + if rule.Title != "" { + t.Errorf("Expected empty title, got %q", rule.Title) + } + if rule.Language != LanguageRego { + t.Errorf("Expected language 'rego', got %q", rule.Language) + } + }) + + t.Run("parse nonexistent file returns error", func(t *testing.T) { + _, err := parseRuleMetadata_Rego(filepath.Join(tempDir, "nonexistent.rego")) + if err == nil { + t.Error("Expected error for nonexistent file") + } + }) + + t.Run("parse empty metadata block", func(t *testing.T) { + regoContent := `# METADATA +package test.empty_metadata + +default allow := true +` + regoPath := filepath.Join(tempDir, "empty_metadata.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Rego(regoPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.PackageName != "test.empty_metadata" { + t.Errorf("Expected package name 'test.empty_metadata', got %q", rule.PackageName) + } + }) +} + +func TestEvalTestcase_Rego(t *testing.T) { + tempDir := t.TempDir() + + t.Run("evaluate passing rule", func(t *testing.T) { + regoContent := `# METADATA +# title: Test Rule +# custom: +# rulenumber: "001_0001" +package test.pass + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + false + error := "never triggered" +} +` + regoPath := filepath.Join(tempDir, "pass_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Name: "TestEntity"` + yamlPath := filepath.Join(tempDir, "pass_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.pass", yamlPath, "001_0001", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure != nil { + t.Errorf("Expected no failure, got: %s", testcase.Failure.Message) + } + if testcase.Name != yamlPath { + t.Errorf("Expected name %q, got %q", yamlPath, testcase.Name) + } + }) + + t.Run("evaluate failing rule", func(t *testing.T) { + regoContent := `# METADATA +# title: Test Rule +# custom: +# rulenumber: "001_0002" +package test.fail + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + not input.Name + error := "Name is required" +} +` + regoPath := filepath.Join(tempDir, "fail_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Value: "NoNameField"` + yamlPath := filepath.Join(tempDir, "fail_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.fail", yamlPath, "001_0002", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure == nil { + t.Error("Expected failure, got nil") + } else if testcase.Failure.Message != "Name is required" { + t.Errorf("Expected message 'Name is required', got %q", testcase.Failure.Message) + } + if testcase.Failure.Type != "AssertionError" { + t.Errorf("Expected type 'AssertionError', got %q", testcase.Failure.Type) + } + }) + + t.Run("evaluate skipped rule with noqa", func(t *testing.T) { + regoContent := `# METADATA +# title: Test Rule +# custom: +# rulenumber: "001_0003" +package test.noqa + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains "Always fails" +` + regoPath := filepath.Join(tempDir, "noqa_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := ` +Documentation: "#noqa:001_0003" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.noqa", yamlPath, "001_0003", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Skipped == nil { + t.Error("Expected testcase to be skipped") + } + if testcase.Failure != nil { + t.Error("Expected no failure when skipped") + } + }) + + t.Run("noqa ignored when ignoreNoqa is true", func(t *testing.T) { + regoContent := `# METADATA +# title: Test Rule +# custom: +# rulenumber: "001_0004" +package test.ignore_noqa + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains "Always fails" +` + regoPath := filepath.Join(tempDir, "ignore_noqa_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := ` +Documentation: "#noqa:001_0004" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "ignore_noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.ignore_noqa", yamlPath, "001_0004", true) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Skipped != nil { + t.Error("Expected testcase not to be skipped when ignoreNoqa is true") + } + if testcase.Failure == nil { + t.Error("Expected failure when ignoreNoqa is true") + } + }) + + t.Run("error reading nonexistent input file", func(t *testing.T) { + regoContent := `package test.error + +import rego.v1 + +default allow := true + +errors contains error if { + false + error := "never triggered" +} +` + regoPath := filepath.Join(tempDir, "error_test_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + _, err = evalTestcase_Rego(regoPath, "data.test.error", filepath.Join(tempDir, "nonexistent.yaml"), "001_0005", false) + if err == nil { + t.Error("Expected error for nonexistent input file") + } + }) + + t.Run("evaluate rule with multiple errors", func(t *testing.T) { + regoContent := `# METADATA +# title: Multiple Errors Rule +# custom: +# rulenumber: "001_0006" +package test.multiple_errors + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + not input.Name + error := "Name is required" +} + +errors contains error if { + not input.Description + error := "Description is required" +} +` + regoPath := filepath.Join(tempDir, "multiple_errors_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Value: "NoRequiredFields"` + yamlPath := filepath.Join(tempDir, "multiple_errors_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.multiple_errors", yamlPath, "001_0006", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure == nil { + t.Error("Expected failure, got nil") + } + // Multiple errors should be joined with newlines + if testcase.Failure != nil { + // Both errors should be present (order may vary) + msg := testcase.Failure.Message + hasNameError := containsSubstring(msg, "Name is required") + hasDescError := containsSubstring(msg, "Description is required") + if !hasNameError || !hasDescError { + t.Errorf("Expected both errors in message, got: %q", msg) + } + } + }) + + t.Run("evaluate rule with complex input", func(t *testing.T) { + regoContent := `# METADATA +# title: Complex Input Rule +# custom: +# rulenumber: "001_0007" +package test.complex + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + count(input.Items) < 2 + error := "At least 2 items required" +} +` + regoPath := filepath.Join(tempDir, "complex_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := ` +Items: + - name: "Item1" + - name: "Item2" + - name: "Item3" +` + yamlPath := filepath.Join(tempDir, "complex_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.complex", yamlPath, "001_0007", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure != nil { + t.Errorf("Expected no failure with 3 items, got: %s", testcase.Failure.Message) + } + }) + + t.Run("testcase time is recorded", func(t *testing.T) { + regoContent := `package test.time + +import rego.v1 + +default allow := true + +errors contains error if { + false + error := "never triggered" +} +` + regoPath := filepath.Join(tempDir, "time_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Name: "Test"` + yamlPath := filepath.Join(tempDir, "time_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.time", yamlPath, "001_0008", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Time <= 0 { + t.Error("Expected positive time value") + } + }) +} + +func TestQuoteRegoMetadataRulenumberIntegration(t *testing.T) { + // Integration test to ensure rulenumber quoting works correctly during eval + tempDir := t.TempDir() + + t.Run("unquoted rulenumber with leading zero is handled", func(t *testing.T) { + // This tests the integration of quoteRegoMetadataRulenumber with evalTestcase_Rego + regoContent := `# METADATA +# title: Leading Zero Rule +# custom: +# rulenumber: 002_0001 +package test.leading_zero + +import rego.v1 + +default allow := true + +errors contains error if { + false + error := "never triggered" +} +` + regoPath := filepath.Join(tempDir, "leading_zero.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Name: "Test"` + yamlPath := filepath.Join(tempDir, "leading_zero_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + // This should not fail due to YAML 1.1 octal interpretation + testcase, err := evalTestcase_Rego(regoPath, "data.test.leading_zero", yamlPath, "002_0001", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase (possibly rulenumber quoting issue): %v", err) + } + + if testcase.Failure != nil { + t.Errorf("Expected no failure, got: %s", testcase.Failure.Message) + } + }) +} + +// Helper function to check if string contains substring +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/lint/lint_test.go b/lint/lint_test.go index 80bce0c..a99e97a 100644 --- a/lint/lint_test.go +++ b/lint/lint_test.go @@ -1,54 +1,630 @@ package lint import ( + "os" + "path/filepath" "testing" ) -// TestAdd tests the Add function to ensure it returns correct results. -func TestLintSingle(t *testing.T) { - // t.Run("single policy skipped", func(t *testing.T) { - // result, err := evalTestsuite("./../policies/001_project_settings/001_0004_strong_password.rego", "./../resources/modelsource-v1") - - // if err != nil { - // t.Errorf("Failed to evaluate") - // } - - // if result.Skipped != 1 { - // t.Errorf("Policy not skipped") - // } - // }) +func TestEvalTestsuite_Rego(t *testing.T) { t.Run("single Rego rule passes", func(t *testing.T) { - rule, _ := parseRuleMetadata_Rego("./../resources/rules/001_0003_security_checks.rego") - result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false) + rule, err := parseRuleMetadata_Rego("./../resources/rules/001_0003_security_checks.rego") + if err != nil { + t.Fatalf("Failed to parse rule metadata: %v", err) + } + result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false, false) if err != nil { - t.Errorf("Failed to evaluate") + t.Fatalf("Failed to evaluate testsuite: %v", err) } if result.Failures != 0 { - t.Errorf("Policy passes") + t.Errorf("Expected no failures, got %d", result.Failures) + } + if result.Tests == 0 { + t.Error("Expected at least one test case") + } + }) + + t.Run("Rego rule with failures", func(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule that always fails + regoContent := `# METADATA +# title: Always Fail Rule +# custom: +# rulenumber: "099_0001" +# input: .*\.yaml +package test.always_fail + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains "Always fails" +` + regoPath := filepath.Join(tempDir, "always_fail.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + // Create a test input file + yamlContent := `Name: "Test"` + yamlPath := filepath.Join(tempDir, "input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: regoPath, + Pattern: ".*\\.yaml", + PackageName: "test.always_fail", + RuleNumber: "099_0001", + Language: LanguageRego, + } + + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Failures != 1 { + t.Errorf("Expected 1 failure, got %d", result.Failures) } }) +} + +func TestEvalTestsuite_Javascript(t *testing.T) { t.Run("single JS rule passes", func(t *testing.T) { - rule, _ := parseRuleMetadata_Javascript("./../resources/rules/001_0002_demo_users_disabled.js") - result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false) + rule, err := parseRuleMetadata_Javascript("./../resources/rules/001_0002_demo_users_disabled.js") + if err != nil { + t.Fatalf("Failed to parse rule metadata: %v", err) + } + result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false, false) if err != nil { - t.Errorf("Failed to evaluate") + t.Fatalf("Failed to evaluate testsuite: %v", err) } if result.Failures != 0 { - t.Errorf("Policy passes") + t.Errorf("Expected no failures, got %d", result.Failures) + } + if result.Tests == 0 { + t.Error("Expected at least one test case") } }) + + t.Run("JS rule with failures", func(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule that always fails + jsContent := ` +const metadata = { + title: "Always Fail Rule", + custom: { rulenumber: "099_0002", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: false, errors: ["Always fails"] }; } +` + jsPath := filepath.Join(tempDir, "always_fail.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } -func TestLintBundle(t *testing.T) { - t.Run("all-rules", func(t *testing.T) { - err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", "", "", false) + // Create a test input file + yamlContent := `Name: "Test"` + yamlPath := filepath.Join(tempDir, "input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0002", + Language: LanguageJavascript, + } + + result, err := evalTestsuite(rule, tempDir, false, false) if err != nil { - t.Errorf("No failures expected: %v", err) + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Failures != 1 { + t.Errorf("Expected 1 failure, got %d", result.Failures) } }) } + +func TestEvalTestsuite_Typescript(t *testing.T) { + t.Run("single TS rule passes", func(t *testing.T) { + rule, err := parseRuleMetadata_Typescript("./../resources/rules/001_0005_typescript_example.ts") + if err != nil { + t.Fatalf("Failed to parse rule metadata: %v", err) + } + + result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Failures != 0 { + t.Errorf("Expected no failures, got %d", result.Failures) + } + }) +} + +func TestEvalTestsuite_WithNoqa(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule that always fails + jsContent := ` +const metadata = { + title: "Noqa Test Rule", + custom: { rulenumber: "099_0003", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: false, errors: ["Should be skipped"] }; +} +` + jsPath := filepath.Join(tempDir, "noqa_test.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create input file with noqa directive + yamlContent := ` +Documentation: "#noqa:099_0003" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0003", + Language: LanguageJavascript, + } + + t.Run("noqa skips the rule", func(t *testing.T) { + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Skipped != 1 { + t.Errorf("Expected 1 skipped, got %d", result.Skipped) + } + if result.Failures != 0 { + t.Errorf("Expected 0 failures when skipped, got %d", result.Failures) + } + }) + + t.Run("ignoreNoqa runs the rule anyway", func(t *testing.T) { + result, err := evalTestsuite(rule, tempDir, true, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Skipped != 0 { + t.Errorf("Expected 0 skipped when ignoreNoqa=true, got %d", result.Skipped) + } + if result.Failures != 1 { + t.Errorf("Expected 1 failure when ignoreNoqa=true, got %d", result.Failures) + } + }) +} + +func TestEvalTestsuite_MultipleFiles(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule that checks for Name field + jsContent := ` +const metadata = { + title: "Name Required Rule", + custom: { rulenumber: "099_0004", input: ".*\\.yaml" } +}; + +function rule(input) { + const errors = []; + if (!input.Name) { + errors.push("Name is required"); + } + return { allow: errors.length === 0, errors }; +} +` + jsPath := filepath.Join(tempDir, "name_required.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create passing input file + err = os.WriteFile(filepath.Join(tempDir, "pass.yaml"), []byte(`Name: "Test"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + // Create failing input file + err = os.WriteFile(filepath.Join(tempDir, "fail.yaml"), []byte(`Value: "NoName"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0004", + Language: LanguageJavascript, + } + + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Tests != 2 { + t.Errorf("Expected 2 tests, got %d", result.Tests) + } + if result.Failures != 1 { + t.Errorf("Expected 1 failure, got %d", result.Failures) + } +} + +func TestCountTotalTestcases(t *testing.T) { + tests := []struct { + name string + testsuites []Testsuite + expected int + }{ + { + name: "Empty testsuites", + testsuites: []Testsuite{}, + expected: 0, + }, + { + name: "Single testsuite with one testcase", + testsuites: []Testsuite{ + {Testcases: []Testcase{{Name: "test1"}}}, + }, + expected: 1, + }, + { + name: "Multiple testsuites", + testsuites: []Testsuite{ + {Testcases: []Testcase{{Name: "test1"}, {Name: "test2"}}}, + {Testcases: []Testcase{{Name: "test3"}}}, + {Testcases: []Testcase{{Name: "test4"}, {Name: "test5"}, {Name: "test6"}}}, + }, + expected: 6, + }, + { + name: "Testsuite with no testcases", + testsuites: []Testsuite{ + {Testcases: []Testcase{}}, + {Testcases: []Testcase{{Name: "test1"}}}, + }, + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := countTotalTestcases(tt.testsuites) + if result != tt.expected { + t.Errorf("Expected %d, got %d", tt.expected, result) + } + }) + } +} + +func TestReadRulesMetadata(t *testing.T) { + t.Run("read rules from resources directory", func(t *testing.T) { + rules, err := ReadRulesMetadata("./../resources/rules") + if err != nil { + t.Fatalf("Failed to read rules metadata: %v", err) + } + + if len(rules) == 0 { + t.Error("Expected at least one rule") + } + + // Verify we have different languages + hasRego := false + hasJS := false + hasTS := false + for _, rule := range rules { + switch rule.Language { + case LanguageRego: + hasRego = true + case LanguageJavascript: + hasJS = true + case LanguageTypescript: + hasTS = true + } + } + + if !hasRego { + t.Error("Expected at least one Rego rule") + } + if !hasJS { + t.Error("Expected at least one JavaScript rule") + } + if !hasTS { + t.Error("Expected at least one TypeScript rule") + } + }) + + t.Run("read from empty directory", func(t *testing.T) { + tempDir := t.TempDir() + rules, err := ReadRulesMetadata(tempDir) + if err != nil { + t.Fatalf("Failed to read rules metadata: %v", err) + } + + if len(rules) != 0 { + t.Errorf("Expected 0 rules from empty directory, got %d", len(rules)) + } + }) + + t.Run("ignores test files", func(t *testing.T) { + tempDir := t.TempDir() + + // Create a regular rule with proper metadata structure + jsContent := ` +const metadata = { + scope: "package", + title: "Test Rule", + description: "A test rule", + custom: { + category: "Test", + rulename: "TestRule", + severity: "LOW", + rulenumber: "001_0001", + remediation: "Fix it", + input: ".*\\.yaml" + } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + err := os.WriteFile(filepath.Join(tempDir, "rule.js"), []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create a test file (should be ignored) + err = os.WriteFile(filepath.Join(tempDir, "rule_test.js"), []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rules, err := ReadRulesMetadata(tempDir) + if err != nil { + t.Fatalf("Failed to read rules metadata: %v", err) + } + + if len(rules) != 1 { + t.Errorf("Expected 1 rule (test file should be ignored), got %d", len(rules)) + } + }) +} + +func TestEvalAll(t *testing.T) { + t.Run("all rules pass", func(t *testing.T) { + err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", "", "", false, false) + if err != nil { + t.Errorf("Expected no failures: %v", err) + } + }) + + t.Run("with xunit report", func(t *testing.T) { + tempDir := t.TempDir() + xunitPath := filepath.Join(tempDir, "report.xml") + + err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", xunitPath, "", false, false) + if err != nil { + t.Errorf("Expected no failures: %v", err) + } + + // Verify report was created + if _, err := os.Stat(xunitPath); os.IsNotExist(err) { + t.Error("Expected xunit report to be created") + } + }) + + t.Run("with json report", func(t *testing.T) { + tempDir := t.TempDir() + jsonPath := filepath.Join(tempDir, "report.json") + + err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", "", jsonPath, false, false) + if err != nil { + t.Errorf("Expected no failures: %v", err) + } + + // Verify report was created + if _, err := os.Stat(jsonPath); os.IsNotExist(err) { + t.Error("Expected json report to be created") + } + }) +} + +func TestEvalAllWithResults(t *testing.T) { + t.Run("returns results", func(t *testing.T) { + result, err := EvalAllWithResults("./../resources/rules", "./../resources/modelsource-v1", "", "", false, false) + if err != nil { + t.Errorf("Expected no failures: %v", err) + } + + testSuites, ok := result.(TestSuites) + if !ok { + t.Fatal("Expected result to be TestSuites") + } + + if len(testSuites.Testsuites) == 0 { + t.Error("Expected at least one testsuite") + } + if len(testSuites.Rules) == 0 { + t.Error("Expected at least one rule in results") + } + }) + + t.Run("reports failures correctly", func(t *testing.T) { + tempDir := t.TempDir() + + // Create a failing rule with proper metadata structure + jsContent := ` +const metadata = { + scope: "package", + title: "Always Fail Rule", + description: "This rule always fails", + custom: { + category: "Test", + rulename: "AlwaysFailRule", + severity: "HIGH", + rulenumber: "099_0099", + remediation: "Cannot be fixed", + input: ".*\\.yaml" + } +}; + +function rule(input) { + return { allow: false, errors: ["Always fails"] }; +} +` + jsPath := filepath.Join(tempDir, "fail.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create input file + yamlPath := filepath.Join(tempDir, "input.yaml") + err = os.WriteFile(yamlPath, []byte(`Name: "Test"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + _, err = EvalAllWithResults(tempDir, tempDir, "", "", false, false) + if err == nil { + t.Error("Expected error due to failures") + } + }) +} + +func TestEvalTestsuite_PatternMatching(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule with specific pattern + jsContent := ` +const metadata = { + title: "Pattern Test Rule", + custom: { rulenumber: "099_0005", input: ".*\\.entity\\.yaml" } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + jsPath := filepath.Join(tempDir, "pattern_test.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create matching file + err = os.WriteFile(filepath.Join(tempDir, "test.entity.yaml"), []byte(`Name: "Test"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + // Create non-matching file + err = os.WriteFile(filepath.Join(tempDir, "test.other.yaml"), []byte(`Name: "Test"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.entity\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0005", + Language: LanguageJavascript, + } + + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + // Only the .entity.yaml file should match + if result.Tests != 1 { + t.Errorf("Expected 1 test (pattern should match only .entity.yaml), got %d", result.Tests) + } +} + +func TestEvalTestsuite_TimeTracking(t *testing.T) { + tempDir := t.TempDir() + + jsContent := ` +const metadata = { + title: "Time Test Rule", + custom: { rulenumber: "099_0006", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + jsPath := filepath.Join(tempDir, "time_test.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + err = os.WriteFile(filepath.Join(tempDir, "input.yaml"), []byte(`Name: "Test"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0006", + Language: LanguageJavascript, + } + + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Time <= 0 { + t.Error("Expected positive total time") + } + + for _, tc := range result.Testcases { + if tc.Time <= 0 { + t.Errorf("Expected positive time for testcase %s", tc.Name) + } + } +} diff --git a/lint/lint_typescript_test.go b/lint/lint_typescript_test.go new file mode 100644 index 0000000..3fac732 --- /dev/null +++ b/lint/lint_typescript_test.go @@ -0,0 +1,534 @@ +package lint + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHashRuleContent(t *testing.T) { + tests := []struct { + name string + content []byte + expected string + }{ + { + name: "Empty content", + content: []byte(""), + expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + name: "Simple content", + content: []byte("hello world"), + expected: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + }, + { + name: "Same content produces same hash", + content: []byte("test content"), + expected: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hashRuleContent(tt.content) + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestTranspileTypescriptRule(t *testing.T) { + tempDir := t.TempDir() + + t.Run("transpile valid TypeScript", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0001" } +}; + +function rule(input: { Name?: string }): { allow: boolean; errors: string[] } { + const errors: string[] = []; + return { allow: true, errors }; +} +` + tsPath := filepath.Join(tempDir, "test_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + result, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("Failed to transpile TypeScript: %v", err) + } + + // Check that TypeScript type annotations are removed + if result == "" { + t.Error("Expected non-empty transpiled code") + } + + // TypeScript types should be removed + if containsString(result, ": string[]") { + t.Error("Expected TypeScript type annotations to be removed") + } + }) + + t.Run("transpile caches result", func(t *testing.T) { + tsContent := `const x: number = 1;` + tsPath := filepath.Join(tempDir, "cached_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // First call + result1, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("First transpile failed: %v", err) + } + + // Second call should use cache + result2, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("Second transpile failed: %v", err) + } + + if result1 != result2 { + t.Error("Expected cached result to match original result") + } + }) + + t.Run("cache invalidated on content change", func(t *testing.T) { + tsPath := filepath.Join(tempDir, "changing_rule.ts") + + // Write initial content + err := os.WriteFile(tsPath, []byte(`const x: number = 1;`), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + result1, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("First transpile failed: %v", err) + } + + // Modify the file + err = os.WriteFile(tsPath, []byte(`const y: number = 2;`), 0644) + if err != nil { + t.Fatalf("Failed to modify test file: %v", err) + } + + result2, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("Second transpile failed: %v", err) + } + + if result1 == result2 { + t.Error("Expected different result after content change") + } + }) + + t.Run("transpile nonexistent file returns error", func(t *testing.T) { + _, err := transpileTypescriptRule(filepath.Join(tempDir, "nonexistent.ts")) + if err == nil { + t.Error("Expected error for nonexistent file") + } + }) + + t.Run("transpile invalid TypeScript returns error", func(t *testing.T) { + tsContent := ` +function rule(input { // missing colon - syntax error + return { allow: true }; +} +` + tsPath := filepath.Join(tempDir, "invalid_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = transpileTypescriptRule(tsPath) + if err == nil { + t.Error("Expected error for invalid TypeScript") + } + }) +} + +func TestParseRuleMetadata_Typescript(t *testing.T) { + tempDir := t.TempDir() + + t.Run("parse valid metadata", func(t *testing.T) { + tsContent := ` +const metadata = { + scope: "package", + title: "Test TypeScript Rule", + description: "A test rule for validation", + authors: ["Test Author "], + custom: { + category: "Testing", + rulename: "TestTypescriptRule", + severity: "HIGH", + rulenumber: "001_0001", + remediation: "Fix the issue", + input: ".*\\.yaml" + } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + tsPath := filepath.Join(tempDir, "valid_metadata.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Typescript(tsPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.Title != "Test TypeScript Rule" { + t.Errorf("Expected title 'Test TypeScript Rule', got %q", rule.Title) + } + if rule.Description != "A test rule for validation" { + t.Errorf("Expected description 'A test rule for validation', got %q", rule.Description) + } + if rule.Category != "Testing" { + t.Errorf("Expected category 'Testing', got %q", rule.Category) + } + if rule.Severity != "HIGH" { + t.Errorf("Expected severity 'HIGH', got %q", rule.Severity) + } + if rule.RuleNumber != "001_0001" { + t.Errorf("Expected rulenumber '001_0001', got %q", rule.RuleNumber) + } + if rule.Remediation != "Fix the issue" { + t.Errorf("Expected remediation 'Fix the issue', got %q", rule.Remediation) + } + if rule.RuleName != "TestTypescriptRule" { + t.Errorf("Expected rulename 'TestTypescriptRule', got %q", rule.RuleName) + } + if rule.Pattern != ".*\\.yaml" { + t.Errorf("Expected pattern '.*\\.yaml', got %q", rule.Pattern) + } + if rule.Language != LanguageTypescript { + t.Errorf("Expected language 'typescript', got %q", rule.Language) + } + if rule.Path != tsPath { + t.Errorf("Expected path %q, got %q", tsPath, rule.Path) + } + }) +} + +func TestEvalTestcase_Typescript(t *testing.T) { + tempDir := t.TempDir() + + t.Run("evaluate passing rule", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0001", input: ".*\\.yaml" } +}; + +function rule(input) { + const errors = []; + if (!input.Name) { + errors.push("Name is required"); + } + return { allow: errors.length === 0, errors }; +} +` + tsPath := filepath.Join(tempDir, "pass_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + yamlContent := `Name: "TestEntity"` + yamlPath := filepath.Join(tempDir, "pass_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Typescript(tsPath, yamlPath, "001_0001", false, tempDir) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure != nil { + t.Errorf("Expected no failure, got: %s", testcase.Failure.Message) + } + if testcase.Name != yamlPath { + t.Errorf("Expected name %q, got %q", yamlPath, testcase.Name) + } + }) + + t.Run("evaluate failing rule", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0002", input: ".*\\.yaml" } +}; + +function rule(input) { + const errors = []; + if (!input.Name) { + errors.push("Name is required"); + } + return { allow: errors.length === 0, errors }; +} +` + tsPath := filepath.Join(tempDir, "fail_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + yamlContent := `Value: "NoNameField"` + yamlPath := filepath.Join(tempDir, "fail_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Typescript(tsPath, yamlPath, "001_0002", false, tempDir) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure == nil { + t.Error("Expected failure, got nil") + } else if testcase.Failure.Message != "Name is required" { + t.Errorf("Expected message 'Name is required', got %q", testcase.Failure.Message) + } + }) + + t.Run("evaluate skipped rule with noqa", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0003", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: false, errors: ["Always fails"] }; +} +` + tsPath := filepath.Join(tempDir, "noqa_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + yamlContent := ` +Documentation: "#noqa:001_0003" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Typescript(tsPath, yamlPath, "001_0003", false, tempDir) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Skipped == nil { + t.Error("Expected testcase to be skipped") + } + if testcase.Failure != nil { + t.Error("Expected no failure when skipped") + } + }) + + t.Run("noqa ignored when ignoreNoqa is true", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0004", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: false, errors: ["Always fails"] }; +} +` + tsPath := filepath.Join(tempDir, "ignore_noqa_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + yamlContent := ` +Documentation: "#noqa:001_0004" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "ignore_noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Typescript(tsPath, yamlPath, "001_0004", true, tempDir) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Skipped != nil { + t.Error("Expected testcase not to be skipped when ignoreNoqa is true") + } + if testcase.Failure == nil { + t.Error("Expected failure when ignoreNoqa is true") + } + }) + + t.Run("error reading nonexistent input file", func(t *testing.T) { + tsContent := ` +const metadata = { title: "Test", custom: { rulenumber: "001_0005" } }; +function rule(input) { return { allow: true, errors: [] }; } +` + tsPath := filepath.Join(tempDir, "error_test_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + _, err = evalTestcase_Typescript(tsPath, filepath.Join(tempDir, "nonexistent.yaml"), "001_0005", false, tempDir) + if err == nil { + t.Error("Expected error for nonexistent input file") + } + }) +} + +func TestRunTypescriptTestCases(t *testing.T) { + tempDir := t.TempDir() + + t.Run("run passing test cases", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0001", input: ".*\\.yaml" } +}; + +function rule(input) { + const errors = []; + if (!input.Name) { + errors.push("Name is required"); + } + return { allow: errors.length === 0, errors }; +} +` + tsPath := filepath.Join(tempDir, "testcase_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + testYamlContent := ` +TestCases: + - name: "Test with name present" + input: + Name: "TestName" + allow: true + - name: "Test with name missing" + input: + Value: "SomeValue" + allow: false +` + testYamlPath := filepath.Join(tempDir, "testcase_rule_test.yaml") + err = os.WriteFile(testYamlPath, []byte(testYamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write test yaml file: %v", err) + } + + rule := Rule{ + Path: tsPath, + Language: LanguageTypescript, + } + + err = runTypescriptTestCases(rule) + if err != nil { + t.Errorf("Expected test cases to pass, got error: %v", err) + } + }) + + t.Run("run failing test cases", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0002", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + tsPath := filepath.Join(tempDir, "failing_testcase_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + testYamlContent := ` +TestCases: + - name: "This should fail because rule always allows but test expects deny" + input: + Name: "TestName" + allow: false +` + testYamlPath := filepath.Join(tempDir, "failing_testcase_rule_test.yaml") + err = os.WriteFile(testYamlPath, []byte(testYamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write test yaml file: %v", err) + } + + rule := Rule{ + Path: tsPath, + Language: LanguageTypescript, + } + + err = runTypescriptTestCases(rule) + if err == nil { + t.Error("Expected test cases to fail") + } + }) + + t.Run("test file not found", func(t *testing.T) { + tsPath := filepath.Join(tempDir, "no_test_file.ts") + err := os.WriteFile(tsPath, []byte(`const metadata = {}; function rule(input) { return { allow: true, errors: [] }; }`), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + rule := Rule{ + Path: tsPath, + Language: LanguageTypescript, + } + + err = runTypescriptTestCases(rule) + if err == nil { + t.Error("Expected error when test file is missing") + } + }) +} + +// Helper function to check if a string contains a substring +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStringHelper(s, substr)) +} + +func containsStringHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/lint/lint_typscript.go b/lint/lint_typscript.go index 1bfc5f6..8f0c8eb 100644 --- a/lint/lint_typscript.go +++ b/lint/lint_typscript.go @@ -76,7 +76,7 @@ func transpileTypescriptRule(rulePath string) (string, error) { return code, nil } -func evalTestcase_Typescript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool) (*Testcase, error) { +func evalTestcase_Typescript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool, modelSourcePath string) (*Testcase, error) { ruleContent, err := transpileTypescriptRule(rulePath) if err != nil { return nil, err @@ -117,9 +117,13 @@ func evalTestcase_Typescript(rulePath string, inputFilePath string, ruleNumber s startTime := time.Now() - // Use the directory containing the input file as the working directory - workingDirectory := filepath.Dir(inputFilePath) - vm := setupJavascriptVM(workingDirectory) + // Use the modelsource path as the working directory, falling back to input file's directory + workingDirectory := modelSourcePath + if workingDirectory == "" { + workingDirectory = filepath.Dir(inputFilePath) + } + allowedRoot := resolveAllowedRoot(modelSourcePath) + vm := setupJavascriptVM(workingDirectory, allowedRoot) _, err = vm.RunString(ruleContent) if err != nil { panic(err) @@ -251,7 +255,7 @@ func runTypescriptTestCases(rule Rule) error { // Use the directory containing the rule file as the working directory workingDirectory := filepath.Dir(rule.Path) - vm := setupJavascriptVM(workingDirectory) + vm := setupJavascriptVM(workingDirectory, workingDirectory) _, err = vm.RunString(ruleContent) if err != nil { panic(err) diff --git a/lint/rules.go b/lint/rules.go index d9cda83..19e3f9a 100644 --- a/lint/rules.go +++ b/lint/rules.go @@ -129,7 +129,7 @@ func runJavaScriptTestCases(rule Rule) error { // Use the directory containing the rule file as the working directory workingDirectory := filepath.Dir(rule.Path) - vm := setupJavascriptVM(workingDirectory) + vm := setupJavascriptVM(workingDirectory, workingDirectory) _, err = vm.RunString(string(ruleContent)) if err != nil { panic(err) diff --git a/lint/utils.go b/lint/utils.go index 1f3af1c..e58c09e 100644 --- a/lint/utils.go +++ b/lint/utils.go @@ -16,6 +16,22 @@ func SetLogger(logger *logrus.Logger) { log = logger } +// resolveAllowedRoot returns a directory path that rules are allowed to access. +// If modelSourcePath is a file path, its directory is used instead. +func resolveAllowedRoot(modelSourcePath string) string { + if modelSourcePath == "" { + return "" + } + info, err := os.Stat(modelSourcePath) + if err != nil { + return modelSourcePath + } + if info.IsDir() { + return modelSourcePath + } + return filepath.Dir(modelSourcePath) +} + // parseNoqaDirective parses a noqa directive and returns the list of rules to skip // and the reason (if provided). // Supports two formats: diff --git a/main.go b/main.go index 3a892f9..258d595 100644 --- a/main.go +++ b/main.go @@ -60,6 +60,7 @@ func main() { JsonFile, _ := cmd.Flags().GetString("json-file") verbose, _ := cmd.Flags().GetBool("verbose") ignoreNoqa, _ := cmd.Flags().GetBool("ignore-noqa") + noCache, _ := cmd.Flags().GetBool("no-cache") log := logrus.New() if verbose { @@ -69,7 +70,7 @@ func main() { } lint.SetLogger(log) - err := lint.EvalAll(rulesDirectory, modelDirectory, xunitReport, JsonFile, ignoreNoqa) + err := lint.EvalAll(rulesDirectory, modelDirectory, xunitReport, JsonFile, ignoreNoqa, !noCache) if err != nil { log.Errorf("lint failed: %s", err) os.Exit(1) @@ -83,6 +84,7 @@ func main() { cmdLint.Flags().StringP("json-file", "j", "", "Path to output file for JSON report. If not provided, no JSON file will be generated") cmdLint.Flags().Bool("verbose", false, "Turn on for debug logs") cmdLint.Flags().Bool("ignore-noqa", false, "Ignore noqa directives in documents") + cmdLint.Flags().Bool("no-cache", false, "Disable caching of lint results. By default, results are cached and reused if rules and model files haven't changed") rootCmd.AddCommand(cmdLint) // Add the serve command diff --git a/resources/rules/001_0004_readfile_feature.js b/resources/rules/001_0004_readfile_feature.js index 0ea4359..8c44b9b 100644 --- a/resources/rules/001_0004_readfile_feature.js +++ b/resources/rules/001_0004_readfile_feature.js @@ -1,15 +1,15 @@ const metadata = { scope: "package", - title: "Project settings must have valid configuration", - description: "Validates project settings by reading related configuration files", + title: "Files in directory must not be empty", + description: "Validates that all files in the current directory contain content and are not empty", authors: ["Test "], custom: { - category: "Configuration", - rulename: "ProjectSettingsValidation", + category: "Integrity", + rulename: "NoEmptyFiles", severity: "LOW", rulenumber: "001_0004", - remediation: "Ensure Settings$ProjectSettings.yaml exists and contains valid configuration", - input: ".*Security\\$ProjectSecurity\\.yaml" + remediation: "Ensure all files in the directory have content. Remove or populate empty files.", + input: ".*Microflows\\$Microflow\\.yaml" } }; @@ -17,22 +17,24 @@ const metadata = { function rule(input = {}) { const errors = []; - // Use mxlint.readfile to read the Settings$ProjectSettings.yaml file - // which should be in the same directory as the Security$ProjectSecurity.yaml input file + // Use mxlint.io.listdir and mxlint.io.readfile to read file in another directory try { - const settingsContent = mxlint.io.readfile("Settings$ProjectSettings.yaml"); - // Check if the settings file contains expected content - if (!settingsContent.includes("$Type:")) { - errors.push("Settings file does not contain expected $Type field"); + const items = mxlint.io.listdir("."); + if (items.length === 0) { + errors.push("No items found in the current directory"); } - - // Verify we can read the content correctly - if (!settingsContent.includes("Settings$ProjectSettings")) { - errors.push("Settings file does not contain Settings$ProjectSettings type"); + for (const item of items) { + const itemPath = item; + if (!mxlint.io.isdir(itemPath)) { + const content = mxlint.io.readfile(itemPath); + if (content === "") { + errors.push("item " + itemPath + " is empty"); + } + } } } catch (e) { - errors.push("Failed to read Settings$ProjectSettings.yaml: " + e.message); + errors.push("Failed to read items in the current directory: " + e.message); } // Determine final authorization decision diff --git a/resources/rules/001_0004_readfile_feature_test.yaml b/resources/rules/001_0004_readfile_feature_test.yaml index 9018323..a561650 100644 --- a/resources/rules/001_0004_readfile_feature_test.yaml +++ b/resources/rules/001_0004_readfile_feature_test.yaml @@ -1,6 +1,7 @@ TestCases: -- name: validates_project_settings_exist +- name: validates_yaml_files_contain_type_field input: - EnableDemoUsers: false + $Type: "Core.Microflow" + Name: "TestMicroflow" allow: true diff --git a/serve/serve.go b/serve/serve.go index 85996e2..0bbf819 100644 --- a/serve/serve.go +++ b/serve/serve.go @@ -219,7 +219,7 @@ func runServe(cmd *cobra.Command, args []string) { lintErr = fmt.Errorf("lint operation panicked: %v", r) } }() - results, lintErr = lint.EvalAllWithResults(rulesDirectory, outputDirectory, "", "", false) + results, lintErr = lint.EvalAllWithResults(rulesDirectory, outputDirectory, "", "", false, true) }() if lintErr != nil {