diff --git a/default.yaml b/default.yaml index 9feef1c..3e53129 100644 --- a/default.yaml +++ b/default.yaml @@ -9,6 +9,8 @@ lint: noCache: false concurrency: 4 regoTrace: false + # skip: maps document path (relative to model source, or absolute) to rules to skip. + # Use the map key "*" (quoted in YAML: "*") to apply the listed rules to every document, after path-specific entries. skip: {} cache: directory: .mendix-cache/mxlint diff --git a/lint/config.go b/lint/config.go index c501d2b..3bad402 100644 --- a/lint/config.go +++ b/lint/config.go @@ -13,6 +13,9 @@ import ( const configFileName = "mxlint.yaml" +// skipPathAllDocuments is the lint.skip map key that applies listed rules to every document. +const skipPathAllDocuments = "*" + type Config struct { Rules ConfigRulesSpec `yaml:"rules"` Lint ConfigLintSpec `yaml:"lint"` @@ -352,6 +355,15 @@ func mergeConfig(base *Config, overlay *Config) { } } +func matchConfigSkipRules(entries []ConfigSkipRule, ruleNumber string) (bool, string) { + for _, entry := range entries { + if entry.Rule == "" || entry.Rule == "*" || entry.Rule == ruleNumber { + return true, formatConfigSkipReason(entry) + } + } + return false, "" +} + func shouldSkipByConfig(inputFilePath string, ruleNumber string, modelSourcePath string) (bool, string) { cfg := getConfig() if cfg == nil || len(cfg.Lint.Skip) == 0 { @@ -363,14 +375,15 @@ func shouldSkipByConfig(inputFilePath string, ruleNumber string, modelSourcePath if !ok { continue } - - for _, entry := range entries { - if entry.Rule == "" || entry.Rule == "*" || entry.Rule == ruleNumber { - return true, formatConfigSkipReason(entry) - } + if skip, reason := matchConfigSkipRules(entries, ruleNumber); skip { + return true, reason } } + if entries, ok := cfg.Lint.Skip[skipPathAllDocuments]; ok { + return matchConfigSkipRules(entries, ruleNumber) + } + return false, "" } diff --git a/lint/config_internal_test.go b/lint/config_internal_test.go index f84f205..95a9a66 100644 --- a/lint/config_internal_test.go +++ b/lint/config_internal_test.go @@ -15,6 +15,7 @@ func TestNormalizeSkipPath(t *testing.T) { {name: "leading slash", input: "/example/doc.yaml", expected: "example/doc.yaml"}, {name: "collapse separators", input: "example//nested/../doc", expected: "example/doc"}, {name: "dot path becomes empty", input: ".", expected: ""}, + {name: "all-documents wildcard key", input: "*", expected: "*"}, } for _, tt := range tests { @@ -130,3 +131,52 @@ func TestShouldSkipByConfig(t *testing.T) { } }) } + +func TestShouldSkipByConfig_AllDocumentsWildcard(t *testing.T) { + SetConfig(&Config{ + Lint: ConfigLintSpec{ + Skip: map[string][]ConfigSkipRule{ + skipPathAllDocuments: { + {Rule: "001_002", Reason: "skip everywhere"}, + }, + }, + }, + }) + t.Cleanup(func() { + SetConfig(&Config{}) + }) + + skip, reason := shouldSkipByConfig("/tmp/modelsource/example/other.yaml", "001_002", "/tmp/modelsource") + if !skip { + t.Fatal("expected skip=true for lint.skip document path *") + } + if reason != "skip everywhere" { + t.Fatalf("expected global skip reason, got %q", reason) + } +} + +func TestShouldSkipByConfig_PathSpecificBeforeAllDocumentsWildcard(t *testing.T) { + SetConfig(&Config{ + Lint: ConfigLintSpec{ + Skip: map[string][]ConfigSkipRule{ + skipPathAllDocuments: { + {Rule: "001_002", Reason: "from star"}, + }, + "example/doc": { + {Rule: "001_002", Reason: "from path"}, + }, + }, + }, + }) + t.Cleanup(func() { + SetConfig(&Config{}) + }) + + skip, reason := shouldSkipByConfig("/tmp/modelsource/example/doc.yaml", "001_002", "/tmp/modelsource") + if !skip { + t.Fatal("expected skip=true") + } + if reason != "from path" { + t.Fatalf("expected path-specific skip to win, got %q", reason) + } +} diff --git a/lint/config_test.go b/lint/config_test.go index 9c581e3..16b6810 100644 --- a/lint/config_test.go +++ b/lint/config_test.go @@ -355,6 +355,41 @@ func TestLoadMergedConfig_NormalizesSkipMapKeys(t *testing.T) { } } +func TestLoadMergedConfig_SkipAllDocumentsWildcardKey(t *testing.T) { + projectDir := t.TempDir() + setDefaultConfigForTest(t, "") + projectConfig := `lint: + skip: + "*": + - rule: "001_002" + reason: global doc skip +` + if err := os.WriteFile(filepath.Join(projectDir, "mxlint.yaml"), []byte(projectConfig), 0644); err != nil { + t.Fatalf("failed to write project config: %v", err) + } + + cfg, err := LoadMergedConfig(projectDir) + if err != nil { + t.Fatalf("LoadMergedConfig returned error: %v", err) + } + + if _, ok := cfg.Lint.Skip["*"]; !ok { + t.Fatalf("expected skip key *, got %#v", cfg.Lint.Skip) + } + SetConfig(cfg) + t.Cleanup(func() { + SetConfig(&Config{}) + }) + + skip, reason := shouldSkipRule("", "001_002", true, "/tmp/modelsource/any/nested/file.yaml", "/tmp/modelsource") + if !skip { + t.Fatal("expected lint.skip * document path to match any file") + } + if reason != "global doc skip" { + t.Fatalf("expected configured reason, got %s", reason) + } +} + func TestLoadMergedConfig_LintConcurrencyAndTrace(t *testing.T) { projectDir := t.TempDir() setDefaultConfigForTest(t, "") diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index 45386cb..27a3345 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -1,6 +1,7 @@ package lint import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -50,10 +51,49 @@ func resolvePath(pathArg string, workingDirectory string, allowedRoot string) (s return absFullPath, nil } +// readYAMLDocumentFromPath reads a YAML file and decodes it into a map suitable for JavaScript rules. +func readYAMLDocumentFromPath(absPath string) (map[string]interface{}, error) { + documentContent, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + var data map[string]interface{} + var node yaml.Node + err = yaml.Unmarshal(documentContent, &node) + if err != nil { + return nil, err + } + err = node.Decode(&data) + if err != nil { + return nil, err + } + return data, nil +} + +// readJSONDocumentFromPath reads a JSON file and decodes it into a value suitable for JavaScript rules. +func readJSONDocumentFromPath(absPath string) (interface{}, error) { + documentContent, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + var data interface{} + err = json.Unmarshal(documentContent, &data) + if err != nil { + return nil, err + } + return data, nil +} + // setupJavascriptVM creates a new sobek VM with the mxlint object exposed. // The mxlint object provides utility functions for JavaScript rules: // - mxlint.io.readfile(path): Reads a file and returns its contents as a string. // The path is resolved relative to the workingDirectory. +// - mxlint.io.readYaml(path): Reads a YAML file and returns its parsed content as an object. +// The path is resolved relative to the workingDirectory. +// - mxlint.io.readJson(path): Reads a JSON file and returns its parsed content. +// The path is resolved relative to the workingDirectory. // - mxlint.io.listdir(path): Lists the contents of a directory and returns an array of filenames. // The path is resolved relative to the workingDirectory. // - mxlint.io.isdir(path): Returns true if the path is a directory, false otherwise. @@ -88,6 +128,44 @@ func setupJavascriptVM(workingDirectory string, allowedRoot string) *sobek.Runti return vm.ToValue(string(content)) }) + // Set the readYaml function + io.Set("readYaml", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readYaml requires a file path argument"))) + } + filepathArg := call.Argument(0).String() + + absPath, err := resolvePath(filepathArg, workingDirectory, allowedRoot) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readYaml: %w", err))) + } + + data, err := readYAMLDocumentFromPath(absPath) + if err != nil { + panic(vm.NewGoError(err)) + } + return vm.ToValue(data) + }) + + // Set the readJson function + io.Set("readJson", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readJson requires a file path argument"))) + } + filepathArg := call.Argument(0).String() + + absPath, err := resolvePath(filepathArg, workingDirectory, allowedRoot) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readJson: %w", err))) + } + + data, err := readJSONDocumentFromPath(absPath) + if err != nil { + panic(vm.NewGoError(err)) + } + return vm.ToValue(data) + }) + // Set the listdir function io.Set("listdir", func(call sobek.FunctionCall) sobek.Value { if len(call.Arguments) == 0 { @@ -144,26 +222,12 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber s ruleContent, _ := os.ReadFile(rulePath) log.Debugf("js file: \n%s", ruleContent) - documentContent, err := os.ReadFile(inputFilePath) + data, err := readYAMLDocumentFromPath(inputFilePath) if err != nil { log.Errorf("Error reading YAML file %q (rule: %q): %s\n", inputFilePath, rulePath, err) return nil, err } - // parse the input file as YAML - var data map[string]interface{} - var node yaml.Node - err = yaml.Unmarshal(documentContent, &node) - if err != nil { - log.Errorf("Error parsing YAML file %q (rule: %q): %s\n", inputFilePath, rulePath, err) - return nil, err - } - err = node.Decode(&data) - if err != nil { - log.Errorf("Error decoding YAML file %q (rule: %q): %s\n", inputFilePath, rulePath, err) - return nil, err - } - // Check if this rule should be skipped based on noqa directives doc, _ := data["Documentation"].(string) shouldSkip, reason := shouldSkipRule(doc, ruleNumber, ignoreNoqa, inputFilePath, modelSourcePath) diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go index 1ba8e5c..bc550c5 100644 --- a/lint/lint_javascript_test.go +++ b/lint/lint_javascript_test.go @@ -159,6 +159,223 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) } +func TestSetupJavascriptVM_MxlintReadYaml(t *testing.T) { + tempDir := t.TempDir() + + yamlContent := `$Type: DomainModels$DomainModel +Entities: + - Name: EntityNonPersist + MaybeGeneralization: + Persistable: false +` + yamlPath := filepath.Join(tempDir, "model.yaml") + if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil { + t.Fatalf("Failed to create test yaml file: %v", err) + } + + t.Run("read yaml with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := `const doc = mxlint.io.readYaml("model.yaml"); +doc.$Type === "DomainModels$DomainModel" && doc.Entities[0].Name === "EntityNonPersist"` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if !result.ToBoolean() { + t.Errorf("Expected parsed YAML object, got %v", result.Export()) + } + }) + + t.Run("read nonexistent yaml throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readYaml("nonexistent.yaml"); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when reading nonexistent yaml file") + } + }) + + t.Run("readYaml without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readYaml(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when calling readYaml without argument") + } + }) + + t.Run("path traversal is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readYaml("../../../etc/passwd"); + "no error"; + } catch (e) { + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) +} + +func TestSetupJavascriptVM_MxlintReadJson(t *testing.T) { + tempDir := t.TempDir() + + jsonContent := `{"$Type":"DomainModels$DomainModel","Entities":[{"Name":"EntityNonPersist"}]}` + jsonPath := filepath.Join(tempDir, "model.json") + if err := os.WriteFile(jsonPath, []byte(jsonContent), 0644); err != nil { + t.Fatalf("Failed to create test json file: %v", err) + } + + t.Run("read json with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := `const doc = mxlint.io.readJson("model.json"); +doc.$Type === "DomainModels$DomainModel" && doc.Entities[0].Name === "EntityNonPersist"` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if !result.ToBoolean() { + t.Errorf("Expected parsed JSON object, got %v", result.Export()) + } + }) + + t.Run("read json array", func(t *testing.T) { + arrayPath := filepath.Join(tempDir, "array.json") + if err := os.WriteFile(arrayPath, []byte(`["a","b"]`), 0644); err != nil { + t.Fatalf("Failed to create array json file: %v", err) + } + + vm := setupJavascriptVM(tempDir, tempDir) + + script := `const items = mxlint.io.readJson("array.json"); +items.length === 2 && items[0] === "a" && items[1] === "b"` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if !result.ToBoolean() { + t.Errorf("Expected parsed JSON array, got %v", result.Export()) + } + }) + + t.Run("read nonexistent json throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readJson("nonexistent.json"); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when reading nonexistent json file") + } + }) + + t.Run("invalid json throws error", func(t *testing.T) { + invalidPath := filepath.Join(tempDir, "invalid.json") + if err := os.WriteFile(invalidPath, []byte(`{$Type: bad}`), 0644); err != nil { + t.Fatalf("Failed to create invalid json file: %v", err) + } + + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readJson("invalid.json"); + "no error"; + } catch (e) { + "error"; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when reading invalid json file") + } + }) + + t.Run("readJson without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readJson(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() == "no error" { + t.Error("Expected an error when calling readJson without argument") + } + }) + + t.Run("path traversal is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir, tempDir) + + script := ` + try { + mxlint.io.readJson("../../../etc/passwd"); + "no error"; + } catch (e) { + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) +} + func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { // Create a temporary directory for test files tempDir := t.TempDir() @@ -559,6 +776,28 @@ func TestMxlintObjectAvailable(t *testing.T) { t.Errorf("Expected mxlint.io.readfile to be a function, got %q", result.String()) } + // Check that mxlint.io.readYaml is a function + script = `typeof mxlint.io.readYaml` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.io.readYaml to be a function, got %q", result.String()) + } + + // Check that mxlint.io.readJson is a function + script = `typeof mxlint.io.readJson` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.io.readJson to be a function, got %q", result.String()) + } + // Check that mxlint.io.listdir is a function script = `typeof mxlint.io.listdir` result, err = vm.RunString(script) diff --git a/mpr/mpr_v2_test.go b/mpr/mpr_v2_test.go index 8330b44..feabc47 100644 --- a/mpr/mpr_v2_test.go +++ b/mpr/mpr_v2_test.go @@ -30,7 +30,7 @@ func TestMPRV2Metadata(t *testing.T) { t.Errorf("Failed to decode metadata file: %v", err) } // check metadata - expectedProductVersion := "10.24.9.81004" + expectedProductVersion := "10.24.16.96987" if metadataObj.ProductVersion != expectedProductVersion { t.Errorf("ProductVersion is incorrect. Expected: %s, Got: %s", expectedProductVersion, metadataObj.ProductVersion) } diff --git a/resources/app-mpr-v2/App.mpr b/resources/app-mpr-v2/App.mpr index 61c44c0..f0106a5 100644 Binary files a/resources/app-mpr-v2/App.mpr and b/resources/app-mpr-v2/App.mpr differ diff --git a/resources/app-mpr-v2/mprcontents/31/0c/310c6774-5724-49fa-ab92-b8ea23d08191.mxunit b/resources/app-mpr-v2/mprcontents/31/0c/310c6774-5724-49fa-ab92-b8ea23d08191.mxunit index 6f4093d..81686bd 100644 Binary files a/resources/app-mpr-v2/mprcontents/31/0c/310c6774-5724-49fa-ab92-b8ea23d08191.mxunit and b/resources/app-mpr-v2/mprcontents/31/0c/310c6774-5724-49fa-ab92-b8ea23d08191.mxunit differ diff --git a/resources/app-mpr-v2/mprcontents/83/92/83929370-5574-4a6d-82b9-d3a11fae8e4a.mxunit b/resources/app-mpr-v2/mprcontents/83/92/83929370-5574-4a6d-82b9-d3a11fae8e4a.mxunit new file mode 100644 index 0000000..9c3062d Binary files /dev/null and b/resources/app-mpr-v2/mprcontents/83/92/83929370-5574-4a6d-82b9-d3a11fae8e4a.mxunit differ diff --git a/resources/modelsource-v2/Module2/DomainModels$DomainModel.yaml b/resources/modelsource-v2/Module2/DomainModels$DomainModel.yaml index fbbd271..11dc0f7 100644 --- a/resources/modelsource-v2/Module2/DomainModels$DomainModel.yaml +++ b/resources/modelsource-v2/Module2/DomainModels$DomainModel.yaml @@ -3,4 +3,38 @@ Annotations: [] Associations: [] CrossAssociations: [] Documentation: "" -Entities: [] +Entities: + - $Type: DomainModels$EntityImpl + AccessRules: [] + Attributes: [] + Documentation: "" + Events: [] + ExportLevel: Hidden + Indexes: [] + MaybeGeneralization: + $Type: DomainModels$NoGeneralization + HasChangedByAttr: false + HasChangedDateAttr: false + HasCreatedDateAttr: false + HasOwnerAttr: false + Persistable: true + Name: EntityPersist + Source: null + ValidationRules: [] + - $Type: DomainModels$EntityImpl + AccessRules: [] + Attributes: [] + Documentation: "" + Events: [] + ExportLevel: Hidden + Indexes: [] + MaybeGeneralization: + $Type: DomainModels$NoGeneralization + HasChangedByAttr: false + HasChangedDateAttr: false + HasCreatedDateAttr: false + HasOwnerAttr: false + Persistable: false + Name: EntityNonPersist + Source: null + ValidationRules: [] diff --git a/resources/modelsource-v2/Module2/MicroflowNonPersist.Microflows$Microflow.yaml b/resources/modelsource-v2/Module2/MicroflowNonPersist.Microflows$Microflow.yaml new file mode 100644 index 0000000..ff2a9c0 --- /dev/null +++ b/resources/modelsource-v2/Module2/MicroflowNonPersist.Microflows$Microflow.yaml @@ -0,0 +1,53 @@ +$Type: Microflows$Microflow +AllowConcurrentExecution: true +AllowedModuleRoles: [] +ApplyEntityAccess: false +ConcurrencyErrorMicroflow: "" +ConcurrenyErrorMessage: + $Type: Texts$Text + Items: [] +Documentation: "" +Excluded: false +ExportLevel: Hidden +MarkAsUsed: false +MicroflowActionInfo: null +MicroflowReturnType: + $Type: DataTypes$VoidType +Name: MicroflowNonPersist +ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$StartEvent + - $Type: Microflows$EndEvent + Documentation: "" + ReturnValue: "" + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CreateChangeAction + Commit: "No" + Entity: Module2.EntityNonPersist + ErrorHandlingType: Rollback + Items: [] + RefreshInClient: false + VariableName: NewEntityNonPersist + AutoGenerateCaption: true + BackgroundColor: Default + Caption: Activity + Disabled: false + Documentation: "" +ReturnVariableName: "" +Url: "" +UrlSearchParameters: [] +WorkflowActionInfo: null +pseudocode: |- + MICROFLOW: MicroflowNonPersist + RETURN TYPE: void + + PSEUDOCODE + ---------- + BEGIN + + // action: Microflows$CreateChangeAction + + + END diff --git a/resources/modelsource-v2/app.yaml b/resources/modelsource-v2/app.yaml index b4bca09..2a128cb 100644 --- a/resources/modelsource-v2/app.yaml +++ b/resources/modelsource-v2/app.yaml @@ -37,6 +37,9 @@ content: - name: MicroflowLoopExample.Microflows$Microflow.yaml type: file path: Module2/MicroflowLoopExample.Microflows$Microflow.yaml + - name: MicroflowNonPersist.Microflows$Microflow.yaml + type: file + path: Module2/MicroflowNonPersist.Microflows$Microflow.yaml - name: MultiLineMicroflow.Microflows$Microflow.yaml type: file path: Module2/MultiLineMicroflow.Microflows$Microflow.yaml diff --git a/resources/rules/001_0006_no_npe_in_microflow.js b/resources/rules/001_0006_no_npe_in_microflow.js new file mode 100644 index 0000000..fc383ab --- /dev/null +++ b/resources/rules/001_0006_no_npe_in_microflow.js @@ -0,0 +1,65 @@ +const metadata = { + scope: "package", + title: "No NPE in microflow", + description: "Disallow Non-persistent entity usage in persistent microflows", + authors: ["Xiwen Cheng "], + custom: { + category: "Security", + rulename: "NonPersistentEntityUsageInPersistentMicroflows", + severity: "HIGH", + rulenumber: "001_0006", + remediation: "Remove the non-persistent entity from the persistent microflow", + input: ".*\\$Microflow\\.yaml" + } +}; + +function entityName(entityRef) { + const parts = entityRef.split("."); + return parts[parts.length - 1]; +} + +function isNPE(entity) { + // read the parent domain model and check if the entity is NPE + // entity: Module2.EntityNonPersist + if (entity === undefined) { + return false; + } + const moduleName = entity.split(".")[0]; + const domainModelPath = [moduleName, "DomainModels$DomainModel.yaml"].join("/"); + const domainModel = mxlint.io.readYaml(domainModelPath); + if (domainModel === undefined || domainModel.Entities === undefined) { + return false; + } + const name = entityName(entity); + const found = domainModel.Entities.find(e => e.Name === name); + if (found === undefined || found.MaybeGeneralization === undefined) { + return false; + } + return found.MaybeGeneralization.Persistable === false; +} + +function rule(input = {}) { + const errors = []; + + // for each entity, check the entity type via the parent domain model if it's NPE or not + for (const object of input.ObjectCollection.Objects) { + if (object.$Type === "Microflows$ActionActivity") { + const entity = object.Action.Entity; + try { + if (isNPE(entity)) { + errors.push("Non-persistent entity used in microflow: " + entity); + } + } catch (e) { + errors.push("Failed to check if entity is NPE: " + e); + } + } + } + // Determine final authorization decision + const allow = errors.length === 0; + + return { + allow, + errors + }; +} + diff --git a/resources/rules/001_0006_no_npe_in_microflow_test.yaml b/resources/rules/001_0006_no_npe_in_microflow_test.yaml new file mode 100644 index 0000000..a8a7819 --- /dev/null +++ b/resources/rules/001_0006_no_npe_in_microflow_test.yaml @@ -0,0 +1,33 @@ +TestCases: +- name: allow when Name is set + allow: true + input: + Name: MicroflowNonPersist + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$StartEvent + - $Type: Microflows$EndEvent + Documentation: "" + ReturnValue: "" + +- name: deny when NonPersistentEntity is used + allow: false + input: + Name: MicroflowNonPersist + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$StartEvent + - $Type: Microflows$EndEvent + Documentation: "" + ReturnValue: "" + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CreateChangeAction + Commit: "No" + Entity: Module2.EntityNonPersist + ErrorHandlingType: Rollback + Items: [] + RefreshInClient: false + VariableName: NewEntityNonPersist \ No newline at end of file diff --git a/resources/rules/Module2/DomainModels$DomainModel.yaml b/resources/rules/Module2/DomainModels$DomainModel.yaml new file mode 100644 index 0000000..f7fdc48 --- /dev/null +++ b/resources/rules/Module2/DomainModels$DomainModel.yaml @@ -0,0 +1,7 @@ +$Type: DomainModels$DomainModel +Entities: + - $Type: DomainModels$EntityImpl + Name: EntityNonPersist + MaybeGeneralization: + $Type: DomainModels$NoGeneralization + Persistable: false