diff --git a/Makefile b/Makefile index 2e4f3cd7..e569ef1a 100644 --- a/Makefile +++ b/Makefile @@ -151,7 +151,7 @@ test-integration: # Usage: make test-mdl MPR=path/to/app.mpr MPR ?= app.mpr test-mdl: build - $(BUILD_DIR)/$(BINARY_NAME) test mdl-examples/doctype-tests/microflow-spec.test.mdl -p $(MPR) + ./scripts/run-mdl-tests.sh "$(abspath $(MPR))" "$(abspath $(BUILD_DIR)/$(BINARY_NAME))" # Lint all code (Go + TypeScript) lint: lint-go lint-ts diff --git a/cmd/mxcli/docker/build.go b/cmd/mxcli/docker/build.go index 6f88d0d3..0974b756 100644 --- a/cmd/mxcli/docker/build.go +++ b/cmd/mxcli/docker/build.go @@ -65,7 +65,7 @@ func Build(opts BuildOptions) error { // Step 2: Resolve MxBuild fmt.Fprintln(w, "Resolving MxBuild...") - mxbuildPath, err := resolveMxBuild(opts.MxBuildPath) + mxbuildPath, err := resolveMxBuild(opts.MxBuildPath, pv.ProductVersion) if err != nil { // Auto-download fallback fmt.Fprintln(w, " MxBuild not found locally, downloading from CDN...") @@ -96,7 +96,7 @@ func Build(opts BuildOptions) error { // Step 4: Pre-build check if !opts.SkipCheck { fmt.Fprintln(w, "Checking project for errors...") - mxPath, err := ResolveMx(opts.MxBuildPath) + mxPath, err := ResolveMxForVersion(opts.MxBuildPath, pv.ProductVersion) if err != nil { fmt.Fprintf(w, " Skipping check: %v\n", err) } else { @@ -383,8 +383,10 @@ func Run(opts RunOptions) error { } // findPADDir searches for a PAD output in the output directory tree. -// It looks for a Dockerfile first, then for docker_compose/Default.yaml as a fallback -// (MxBuild 11.6.3+ generates docker_compose instead of a Dockerfile). +// It recognizes: +// - classic PAD output with a Dockerfile +// - PAD output with docker_compose/Default.yaml +// - newer PAD output already extracted at the build root (app/bin/etc/lib) func findPADDir(outputDir string) (string, error) { // Check output dir itself if isPADDir(outputDir) { @@ -405,11 +407,12 @@ func findPADDir(outputDir string) (string, error) { } } - return "", fmt.Errorf("no PAD output found in %s (looked for Dockerfile or docker_compose/Default.yaml)", outputDir) + return "", fmt.Errorf("no PAD output found in %s (looked for Dockerfile, docker_compose/Default.yaml, or extracted app/bin/etc/lib layout)", outputDir) } // isPADDir checks if a directory contains PAD output. -// Recognizes both Dockerfile-based PAD and docker_compose-based PAD. +// Recognizes Dockerfile-based PAD, docker_compose-based PAD, and newer +// already-extracted PAD layout at the build root. func isPADDir(dir string) bool { if _, err := os.Stat(filepath.Join(dir, "Dockerfile")); err == nil { return true @@ -417,7 +420,35 @@ func isPADDir(dir string) bool { if _, err := os.Stat(filepath.Join(dir, "docker_compose", "Default.yaml")); err == nil { return true } - return false + return hasExtractedPADLayout(dir) +} + +func hasExtractedPADLayout(dir string) bool { + requiredDirs := []string{ + filepath.Join(dir, "app"), + filepath.Join(dir, "bin"), + filepath.Join(dir, "etc"), + filepath.Join(dir, "lib"), + } + for _, path := range requiredDirs { + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return false + } + } + + requiredFiles := []string{ + filepath.Join(dir, "bin", "start"), + filepath.Join(dir, "lib", "runtime", "launcher", "runtimelauncher.jar"), + } + for _, path := range requiredFiles { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + } + + return true } // flattenPADDir moves contents from a PAD subdirectory to the output root directory. diff --git a/cmd/mxcli/docker/build_test.go b/cmd/mxcli/docker/build_test.go index ef72528c..ab13522c 100644 --- a/cmd/mxcli/docker/build_test.go +++ b/cmd/mxcli/docker/build_test.go @@ -434,6 +434,34 @@ func TestFindPADDir_DockerCompose_InSubdir(t *testing.T) { } } +func TestFindPADDir_ExtractedLayoutInRoot(t *testing.T) { + dir := t.TempDir() + for _, subdir := range []string{ + filepath.Join(dir, "app"), + filepath.Join(dir, "etc"), + filepath.Join(dir, "bin"), + filepath.Join(dir, "lib", "runtime", "launcher"), + } { + if err := os.MkdirAll(subdir, 0755); err != nil { + t.Fatalf("mkdir %s: %v", subdir, err) + } + } + if err := os.WriteFile(filepath.Join(dir, "bin", "start"), []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatalf("write start script: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "lib", "runtime", "launcher", "runtimelauncher.jar"), []byte("fake"), 0644); err != nil { + t.Fatalf("write launcher: %v", err) + } + + padDir, err := findPADDir(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if padDir != dir { + t.Errorf("expected %s, got %s", dir, padDir) + } +} + func TestFindPADDir_PrefersDockerfile(t *testing.T) { dir := t.TempDir() // Both Dockerfile and docker_compose exist — Dockerfile wins diff --git a/cmd/mxcli/docker/check.go b/cmd/mxcli/docker/check.go index 1517bb4e..77dd900d 100644 --- a/cmd/mxcli/docker/check.go +++ b/cmd/mxcli/docker/check.go @@ -10,6 +10,8 @@ import ( "path/filepath" "runtime" "strings" + + "github.com/mendixlabs/mxcli/sdk/mpr" ) // CheckOptions configures the mx check command. @@ -44,7 +46,15 @@ func Check(opts CheckOptions) error { } // Resolve mx binary - mxPath, err := ResolveMx(opts.MxBuildPath) + projectVersion := "" + if opts.ProjectPath != "" { + if reader, err := mpr.Open(opts.ProjectPath); err == nil { + projectVersion = reader.ProjectVersion().ProductVersion + reader.Close() + } + } + + mxPath, err := ResolveMxForVersion(opts.MxBuildPath, projectVersion) if err != nil { return err } @@ -88,12 +98,25 @@ func mxBinaryName() string { return "mx" } +func mxBinaryNames() []string { + if runtime.GOOS == "windows" { + return []string{"mx.exe", "mx"} + } + return []string{"mx"} +} + // ResolveMx finds the mx executable. // Priority: derive from mxbuild path > PATH lookup. func ResolveMx(mxbuildPath string) (string, error) { + return ResolveMxForVersion(mxbuildPath, "") +} + +// ResolveMxForVersion finds the mx executable, preferring the project's exact +// Mendix version when multiple local installations or cached downloads exist. +func ResolveMxForVersion(mxbuildPath, preferredVersion string) (string, error) { if mxbuildPath != "" { // Resolve mxbuild first to handle directory paths - resolvedMxBuild, err := resolveMxBuild(mxbuildPath) + resolvedMxBuild, err := resolveMxBuild(mxbuildPath, preferredVersion) if err == nil { // Look for mx in the same directory as mxbuild mxDir := filepath.Dir(resolvedMxBuild) @@ -123,24 +146,47 @@ func ResolveMx(mxbuildPath string) (string, error) { return p, nil } + if preferredVersion != "" { + if studioProDir := ResolveStudioProDir(preferredVersion); studioProDir != "" { + for _, name := range mxBinaryNames() { + candidate := filepath.Join(studioProDir, "modeler", name) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + } + } + // Try OS-specific known locations (Studio Pro on Windows) before cached downloads. - for _, pattern := range mendixSearchPaths(mxBinaryName()) { - matches, _ := filepath.Glob(pattern) - if len(matches) > 0 { - return matches[len(matches)-1], nil + if matches := globVersionedMatches(mendixSearchPaths(mxBinaryName())); len(matches) > 0 { + if exact := exactVersionedPath(matches, preferredVersion); exact != "" { + return exact, nil + } + if newest := NewestVersionedPath(matches); newest != "" { + return newest, nil } } - // Try cached mxbuild installations (~/.mxcli/mxbuild/*/modeler/mx). - // NOTE: lexicographic sort is imperfect for versions (e.g. "9.x" > "10.x"), - // but this is a fallback-of-last-resort — in practice users typically have - // only one mxbuild version installed. - if home, err := os.UserHomeDir(); err == nil { - matches, _ := filepath.Glob(filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", mxBinaryName())) - if len(matches) > 0 { - return matches[len(matches)-1], nil + if preferredVersion != "" { + if p := CachedMxPath(preferredVersion); p != "" { + return p, nil } } + if p := AnyCachedMxPath(); p != "" { + return p, nil + } return "", fmt.Errorf("mx not found; specify --mxbuild-path pointing to Mendix installation directory") } + +func CachedMxPath(version string) string { + cacheDir, err := MxBuildCacheDir(version) + if err != nil { + return "" + } + return cachedBinaryPath(cacheDir, mxBinaryNames()) +} + +func AnyCachedMxPath() string { + return anyCachedBinaryPath(mxBinaryNames()) +} diff --git a/cmd/mxcli/docker/check_test.go b/cmd/mxcli/docker/check_test.go index ae7f6872..c760ac1c 100644 --- a/cmd/mxcli/docker/check_test.go +++ b/cmd/mxcli/docker/check_test.go @@ -118,3 +118,35 @@ func TestCheck_SkipUpdateWidgetsFlag(t *testing.T) { t.Error("check should still be called") } } + +func TestResolveMxForVersion_PrefersExactCachedVersion(t *testing.T) { + dir := t.TempDir() + setTestHomeDir(t, dir) + // Point PATH at an empty temp dir (rather than clearing it) so exec.LookPath + // still works for any other testing infrastructure but can't find mx. + t.Setenv("PATH", t.TempDir()) + + versions := []string{"9.24.40.80973", "11.6.3", "11.9.0"} + var expected string + for _, version := range versions { + modelerDir := filepath.Join(dir, ".mxcli", "mxbuild", version, "modeler") + if err := os.MkdirAll(modelerDir, 0755); err != nil { + t.Fatal(err) + } + bin := filepath.Join(modelerDir, mxBinaryName()) + if err := os.WriteFile(bin, []byte("fake"), 0755); err != nil { + t.Fatal(err) + } + if version == "11.9.0" { + expected = bin + } + } + + result, err := ResolveMxForVersion("", "11.9.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != expected { + t.Errorf("expected exact cached mx %s, got %s", expected, result) + } +} diff --git a/cmd/mxcli/docker/detect.go b/cmd/mxcli/docker/detect.go index 9c606f96..09338a49 100644 --- a/cmd/mxcli/docker/detect.go +++ b/cmd/mxcli/docker/detect.go @@ -73,7 +73,12 @@ func findMxBuildInDir(dir string) string { // CDN downloads are Linux binaries that cannot run natively on Windows. // The explicit path can be the binary itself or a directory containing it // (e.g., a Mendix installation root with modeler/mxbuild inside). -func resolveMxBuild(explicitPath string) (string, error) { +func resolveMxBuild(explicitPath string, preferredVersion ...string) (string, error) { + targetVersion := "" + if len(preferredVersion) > 0 { + targetVersion = preferredVersion[0] + } + if explicitPath != "" { info, err := os.Stat(explicitPath) if err != nil { @@ -94,13 +99,31 @@ func resolveMxBuild(explicitPath string) (string, error) { return p, nil } + // Try the exact Studio Pro installation for the project version first. + // This keeps Docker builds on the same Mendix line as the project when + // multiple installs are present locally. + if targetVersion != "" { + if studioProDir := ResolveStudioProDir(targetVersion); studioProDir != "" { + if found := findMxBuildInDir(studioProDir); found != "" { + return found, nil + } + } + } + // Try OS-specific known locations (Studio Pro on Windows) BEFORE cached downloads. // On Windows, CDN downloads are Linux binaries — Studio Pro's mxbuild.exe is preferred. - for _, pattern := range mxbuildSearchPaths() { - matches, _ := filepath.Glob(pattern) - if len(matches) > 0 { - // Return the last match (likely newest version) - return matches[len(matches)-1], nil + if matches := globVersionedMatches(mxbuildSearchPaths()); len(matches) > 0 { + if exact := exactVersionedPath(matches, targetVersion); exact != "" { + return exact, nil + } + if newest := NewestVersionedPath(matches); newest != "" { + return newest, nil + } + } + + if targetVersion != "" { + if p := CachedMxBuildPath(targetVersion); p != "" { + return p, nil } } diff --git a/cmd/mxcli/docker/detect_test.go b/cmd/mxcli/docker/detect_test.go index 5b9c46ae..fbfc41ae 100644 --- a/cmd/mxcli/docker/detect_test.go +++ b/cmd/mxcli/docker/detect_test.go @@ -268,3 +268,35 @@ func TestResolveMxBuild_PrefersStudioProOverCache(t *testing.T) { t.Errorf("expected Studio Pro binary %s, got %s (should prefer Studio Pro over cache)", studioBin, result) } } + +func TestResolveMxBuild_PrefersExactCachedVersion(t *testing.T) { + dir := t.TempDir() + setTestHomeDir(t, dir) + // Point PATH at an empty temp dir (rather than clearing it) so exec.LookPath + // still works for any other testing infrastructure but can't find mxbuild. + t.Setenv("PATH", t.TempDir()) + + versions := []string{"9.24.40.80973", "11.6.3", "11.9.0"} + var expected string + for _, version := range versions { + modelerDir := filepath.Join(dir, ".mxcli", "mxbuild", version, "modeler") + if err := os.MkdirAll(modelerDir, 0755); err != nil { + t.Fatal(err) + } + bin := filepath.Join(modelerDir, mxbuildBinaryName()) + if err := os.WriteFile(bin, []byte("fake"), 0755); err != nil { + t.Fatal(err) + } + if version == "11.9.0" { + expected = bin + } + } + + result, err := resolveMxBuild("", "11.9.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != expected { + t.Errorf("expected exact cached version %s, got %s", expected, result) + } +} diff --git a/cmd/mxcli/docker/download.go b/cmd/mxcli/docker/download.go index 39f16ee4..cd95eb1e 100644 --- a/cmd/mxcli/docker/download.go +++ b/cmd/mxcli/docker/download.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" ) @@ -44,7 +45,18 @@ func CachedMxBuildPath(version string) string { if err != nil { return "" } - for _, name := range mxbuildBinaryNames() { + return cachedBinaryPath(cacheDir, mxbuildBinaryNames()) +} + +// AnyCachedMxBuildPath searches for any cached mxbuild version. +// Returns the path to the newest cached mxbuild binary found, or empty string. +// On Windows, checks both "mxbuild.exe" and "mxbuild" (Linux binary cached for Docker). +func AnyCachedMxBuildPath() string { + return anyCachedBinaryPath(mxbuildBinaryNames()) +} + +func cachedBinaryPath(cacheDir string, names []string) string { + for _, name := range names { bin := filepath.Join(cacheDir, "modeler", name) if info, err := os.Stat(bin); err == nil && !info.IsDir() { return bin @@ -53,22 +65,122 @@ func CachedMxBuildPath(version string) string { return "" } -// AnyCachedMxBuildPath searches for any cached mxbuild version. -// Returns the path to the first mxbuild binary found, or empty string. -// On Windows, checks both "mxbuild.exe" and "mxbuild" (Linux binary cached for Docker). -func AnyCachedMxBuildPath() string { +func anyCachedBinaryPath(names []string) string { home, err := os.UserHomeDir() if err != nil { return "" } - for _, name := range mxbuildBinaryNames() { + var matches []string + for _, name := range names { pattern := filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", name) - matches, _ := filepath.Glob(pattern) - if len(matches) > 0 { - return matches[len(matches)-1] + found, _ := filepath.Glob(pattern) + matches = append(matches, found...) + } + return NewestVersionedPath(matches) +} + +func globVersionedMatches(patterns []string) []string { + var matches []string + for _, pattern := range patterns { + found, _ := filepath.Glob(pattern) + matches = append(matches, found...) + } + return matches +} + +func exactVersionedPath(paths []string, version string) string { + if version == "" { + return "" + } + var exact []string + for _, path := range paths { + if versionFromPath(path) == version { + exact = append(exact, path) } } - return "" + return NewestVersionedPath(exact) +} + +// NewestVersionedPath selects the lexicographically-highest "versioned" +// directory from paths, where "versioned" means the grandparent directory +// name parses as a dotted numeric version (`11.9.0`). Paths whose version +// cannot be parsed compare as a pure lexicographic fallback, but always rank +// below any parseable version. Used by both the mx-binary resolver and the +// integration test harness. +func NewestVersionedPath(paths []string) string { + var best string + var bestVersion []int + bestValid := false + + for _, path := range paths { + versionParts, ok := parseVersionParts(versionFromPath(path)) + switch { + case best == "": + best = path + bestVersion = versionParts + bestValid = ok + case ok && !bestValid: + best = path + bestVersion = versionParts + bestValid = true + case ok && bestValid: + if cmp := compareVersionParts(versionParts, bestVersion); cmp > 0 || (cmp == 0 && path > best) { + best = path + bestVersion = versionParts + } + case !ok && !bestValid && path > best: + best = path + } + } + + return best +} + +func versionFromPath(path string) string { + versionDir := filepath.Dir(filepath.Dir(path)) + return filepath.Base(versionDir) +} + +func parseVersionParts(version string) ([]int, bool) { + if version == "" { + return nil, false + } + parts := strings.Split(version, ".") + values := make([]int, 0, len(parts)) + for _, part := range parts { + if part == "" { + return nil, false + } + value, err := strconv.Atoi(part) + if err != nil { + return nil, false + } + values = append(values, value) + } + return values, true +} + +func compareVersionParts(left, right []int) int { + maxLen := len(left) + if len(right) > maxLen { + maxLen = len(right) + } + for i := 0; i < maxLen; i++ { + var l, r int + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] + } + switch { + case l < r: + return -1 + case l > r: + return 1 + } + } + return 0 } // DownloadMxBuild downloads and extracts MxBuild for the given version. diff --git a/cmd/mxcli/docker/download_test.go b/cmd/mxcli/docker/download_test.go index f0c0e9c5..c14e91cc 100644 --- a/cmd/mxcli/docker/download_test.go +++ b/cmd/mxcli/docker/download_test.go @@ -97,6 +97,32 @@ func TestAnyCachedMxBuildPath_Found(t *testing.T) { } } +func TestAnyCachedMxBuildPath_PicksNewestNumericVersion(t *testing.T) { + dir := t.TempDir() + setTestHomeDir(t, dir) + + versions := []string{"9.24.40.80973", "11.6.3", "11.9.0"} + var newest string + for _, version := range versions { + modelerDir := filepath.Join(dir, ".mxcli", "mxbuild", version, "modeler") + if err := os.MkdirAll(modelerDir, 0755); err != nil { + t.Fatal(err) + } + bin := filepath.Join(modelerDir, mxbuildBinaryName()) + if err := os.WriteFile(bin, []byte("fake"), 0755); err != nil { + t.Fatal(err) + } + if version == "11.9.0" { + newest = bin + } + } + + path := AnyCachedMxBuildPath() + if path != newest { + t.Errorf("expected newest cached mxbuild %s, got %s", newest, path) + } +} + func TestRuntimeCDNURL(t *testing.T) { url := RuntimeCDNURL("11.6.3") expected := "https://cdn.mendix.com/runtime/mendix-11.6.3.tar.gz" diff --git a/cmd/mxcli/testrunner/generator.go b/cmd/mxcli/testrunner/generator.go index 6dc970ec..9171680b 100644 --- a/cmd/mxcli/testrunner/generator.go +++ b/cmd/mxcli/testrunner/generator.go @@ -152,8 +152,9 @@ func writeExpectAssertion(b *strings.Builder, testID string, exp Expect) { // varPattern matches $VariableName in MDL ($ followed by word characters). var varPattern = regexp.MustCompile(`\$([A-Za-z_][A-Za-z0-9_]*)`) -// assignPattern matches "$var = CALL" or "$var = CREATE" at the start of a statement. -var assignPattern = regexp.MustCompile(`^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:CALL|CREATE)`) +// assignPattern matches "$var = CALL" or "$var = CREATE" at the start of any +// statement line inside the test body. +var assignPattern = regexp.MustCompile(`(?m)^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:CALL|CREATE)`) // extractVariableNames finds all user-defined variable names in the MDL body. // Returns the set of variable names (without $ prefix). @@ -243,11 +244,6 @@ func rewriteWithErrorHandling(lines []string, testID string) []string { result = append(result, " SET $AllPassed = false;") result = append(result, " RETURN $AllPassed;") result = append(result, "};") - } else if isSideEffectStatement(trimmed) && strings.HasSuffix(trimmed, ";") { - // DELETE, COMMIT, CHANGE — add ON ERROR CONTINUE so validation - // feedback or entity state issues don't crash the test suite. - withoutSemicolon := strings.TrimSuffix(trimmed, ";") - result = append(result, withoutSemicolon+" ON ERROR CONTINUE;") } else { result = append(result, line) } @@ -286,9 +282,6 @@ func rewriteForThrowsTest(lines []string, testID string, didThrowVar string) []s result = append(result, joined+" ON ERROR {") result = append(result, fmt.Sprintf(" SET %s = true;", didThrowVar)) result = append(result, "};") - } else if isSideEffectStatement(trimmed) && strings.HasSuffix(trimmed, ";") { - withoutSemicolon := strings.TrimSuffix(trimmed, ";") - result = append(result, withoutSemicolon+" ON ERROR CONTINUE;") } else { result = append(result, line) } @@ -304,16 +297,6 @@ func containsCallMicroflow(s string) bool { strings.Contains(upper, "CALL NANOFLOW") } -// isSideEffectStatement checks if a trimmed line is a DELETE, COMMIT, or CHANGE -// statement that should get ON ERROR CONTINUE to prevent validation feedback -// or other entity state issues from crashing the test suite. -func isSideEffectStatement(trimmed string) bool { - upper := strings.ToUpper(trimmed) - return strings.HasPrefix(upper, "DELETE ") || - strings.HasPrefix(upper, "COMMIT ") || - strings.HasPrefix(upper, "CHANGE ") -} - // escapeMDLString escapes single quotes for MDL string literals. func escapeMDLString(s string) string { return strings.ReplaceAll(s, "'", "''") diff --git a/cmd/mxcli/testrunner/generator_test.go b/cmd/mxcli/testrunner/generator_test.go new file mode 100644 index 00000000..06d1c324 --- /dev/null +++ b/cmd/mxcli/testrunner/generator_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +func TestRewriteWithErrorHandling_OnlyWrapsCalls(t *testing.T) { + lines := []string{ + "$p1 = CALL MICROFLOW MfTest.M012_CreateEntity(Name = 'A', Code = '1');", + "CHANGE $p1 (IsActive = false);", + "COMMIT $p1;", + "DELETE $p1;", + } + + got := rewriteWithErrorHandling(lines, "test_1") + joined := strings.Join(got, "\n") + + if strings.Contains(joined, "CHANGE $p1 (IsActive = false) ON ERROR CONTINUE;") { + t.Fatalf("CHANGE statement must not get ON ERROR CONTINUE:\n%s", joined) + } + if strings.Contains(joined, "COMMIT $p1 ON ERROR CONTINUE;") { + t.Fatalf("COMMIT statement must not get ON ERROR CONTINUE:\n%s", joined) + } + if strings.Contains(joined, "DELETE $p1 ON ERROR CONTINUE;") { + t.Fatalf("DELETE statement must not get ON ERROR CONTINUE:\n%s", joined) + } +} + +func TestGenerateTestRunner_ParsesWhenTestUsesChangeAndListOps(t *testing.T) { + suite := &TestSuite{ + Name: "microflow-spec", + Tests: []TestCase{ + { + ID: "test_1", + Name: "List FILTER by IsActive", + MDL: strings.Join([]string{ + "$p1 = CALL MICROFLOW MfTest.M012_CreateEntity(Name = 'Active', Code = 'FI-001');", + "$p2 = CALL MICROFLOW MfTest.M012_CreateEntity(Name = 'Inactive', Code = 'FI-002');", + "CHANGE $p2 (IsActive = false);", + "$list = CREATE LIST OF MfTest.Product;", + "ADD $p1 TO $list;", + "ADD $p2 TO $list;", + "$filtered = CALL MICROFLOW MfTest.M043_ListFilter(ProductList = $list);", + "$count = CALL MICROFLOW MfTest.M051_AggregateCount(ProductList = $filtered);", + }, "\n"), + Expects: []Expect{ + {Variable: "$count", Operator: "=", Value: "1"}, + }, + }, + }, + } + + mdl := GenerateTestRunner(suite) + if strings.Contains(mdl, "CHANGE $p2 (IsActive = false) ON ERROR CONTINUE;") { + t.Fatalf("generated runner must not add ON ERROR to CHANGE:\n%s", mdl) + } + + _, errs := visitor.Build(mdl) + if len(errs) > 0 { + t.Fatalf("generated runner should parse, got error: %v\n%s", errs[0], mdl) + } +} + +func TestGenerateTestRunner_RenamesAllAssignmentsInTestBlock(t *testing.T) { + suite := &TestSuite{ + Name: "rename-spec", + Tests: []TestCase{ + { + ID: "test_11", + Name: "Multiple assignments", + MDL: strings.Join([]string{ + "$product = CALL MICROFLOW MfTest.M012_CreateEntity(Name = 'ToUpdate', Code = 'TP-002');", + "COMMIT $product;", + "$result = CALL MICROFLOW MfTest.M015_UpdateEntity(Product = $product, NewName = 'UpdatedProduct');", + "$list = CREATE LIST OF MfTest.Product;", + "ADD $product TO $list;", + }, "\n"), + Expects: []Expect{ + {Variable: "$result", Operator: "=", Value: "true"}, + }, + }, + }, + } + + mdl := GenerateTestRunner(suite) + for _, want := range []string{ + "$product_1 = CALL MICROFLOW", + "COMMIT $product_1;", + "$result_1 = CALL MICROFLOW", + "$list_1 = CREATE LIST OF MfTest.Product;", + "ADD $product_1 TO $list_1;", + "$result_1 = true", + } { + if !strings.Contains(mdl, want) { + t.Fatalf("generated runner is missing renamed fragment %q:\n%s", want, mdl) + } + } +} diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index 55910100..1ed4d6ff 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -131,6 +131,10 @@ func Run(opts RunOptions) (*SuiteResult, error) { // Step 4: Build and restart dockerDir := filepath.Join(filepath.Dir(opts.ProjectPath), ".docker") + if err := ensureDockerStack(opts.ProjectPath, dockerDir, w); err != nil { + cleanup(opts.ProjectPath, origAfterStartup, w) + return nil, fmt.Errorf("docker init: %w", err) + } if !opts.SkipBuild { fmt.Fprintln(w, "Building project...") @@ -442,6 +446,18 @@ func findMxcli() (string, error) { return "", fmt.Errorf("mxcli binary not found (ensure it's in PATH or current directory)") } +func ensureDockerStack(projectPath, dockerDir string, w io.Writer) error { + composePath := filepath.Join(dockerDir, "docker-compose.yml") + if _, err := os.Stat(composePath); err == nil { + return nil + } + return docker.Init(docker.InitOptions{ + ProjectPath: projectPath, + OutputDir: dockerDir, + Stdout: w, + }) +} + // runCompose executes a docker compose command in the given directory. func runCompose(dockerDir string, args ...string) error { cmd := exec.Command(docker.ContainerCLI(), append([]string{"compose"}, args...)...) diff --git a/cmd/mxcli/testrunner/runner_test.go b/cmd/mxcli/testrunner/runner_test.go new file mode 100644 index 00000000..0158121c --- /dev/null +++ b/cmd/mxcli/testrunner/runner_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureDockerStack_CreatesComposeFiles(t *testing.T) { + projectDir := t.TempDir() + projectPath := filepath.Join(projectDir, "test-source.mpr") + dockerDir := filepath.Join(projectDir, ".docker") + + var buf strings.Builder + if err := ensureDockerStack(projectPath, dockerDir, &buf); err != nil { + t.Fatalf("ensureDockerStack failed: %v", err) + } + + for _, path := range []string{ + filepath.Join(dockerDir, "docker-compose.yml"), + filepath.Join(dockerDir, ".env"), + filepath.Join(dockerDir, ".env.example"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s to exist", path) + } + } + + if err := ensureDockerStack(projectPath, dockerDir, &buf); err != nil { + t.Fatalf("ensureDockerStack should be idempotent: %v", err) + } +} diff --git a/mdl-examples/doctype-tests/06-rest-client-examples.mdl b/mdl-examples/doctype-tests/06-rest-client-examples.mdl index 3eff5021..e8384f12 100644 --- a/mdl-examples/doctype-tests/06-rest-client-examples.mdl +++ b/mdl-examples/doctype-tests/06-rest-client-examples.mdl @@ -1301,13 +1301,20 @@ create import mapping RestTest.IMM_Order -- -- Uses FIND OR CREATE to upsert: find existing by KEY, create if not found. +create persistent entity RestTest.PetRecord ( + PetId: Integer, + Name: String, + Status: String +); +/ + create import mapping RestTest.IMM_UpsertPet with json structure RestTest.JSON_Pet { - find or create RestTest.PetResponse { + find or create RestTest.PetRecord { PetId = id key, Name = name, - status = status + Status = status } }; diff --git a/mdl-examples/doctype-tests/workflow-user-targeting.mdl b/mdl-examples/doctype-tests/workflow-user-targeting.mdl index 19fe3bf8..549b1cf4 100644 --- a/mdl-examples/doctype-tests/workflow-user-targeting.mdl +++ b/mdl-examples/doctype-tests/workflow-user-targeting.mdl @@ -35,10 +35,10 @@ CREATE MICROFLOW WFTarget.ACT_GetGroups ( $Workflow: System.Workflow, $Context: WFTarget.Request ) -RETURNS List of System.UserGroup AS $Groups +RETURNS List of System.WorkflowGroup AS $Groups BEGIN @position(200,200) - RETRIEVE $Groups FROM System.UserGroup; + RETRIEVE $Groups FROM System.WorkflowGroup; @position(400,200) RETURN $Groups; END; / diff --git a/mdl/backend/microflow.go b/mdl/backend/microflow.go index c839eb02..9db108fc 100644 --- a/mdl/backend/microflow.go +++ b/mdl/backend/microflow.go @@ -20,6 +20,12 @@ type MicroflowBackend interface { // map. Used by diff-local and other callers that have raw map data. ParseMicroflowFromRaw(raw map[string]any, unitID, containerID model.ID) *microflows.Microflow + // ParseMicroflowBSON parses raw microflow BSON bytes into a Microflow. + // Used by the executor to inspect microflows it has not necessarily + // loaded via ListMicroflows (e.g. to resolve a CALL MICROFLOW's return + // type from its raw unit). + ParseMicroflowBSON(contents []byte, unitID, containerID model.ID) (*microflows.Microflow, error) + ListNanoflows() ([]*microflows.Nanoflow, error) GetNanoflow(id model.ID) (*microflows.Nanoflow, error) CreateNanoflow(nf *microflows.Nanoflow) error diff --git a/mdl/backend/mock/backend.go b/mdl/backend/mock/backend.go index 2e07a1e1..26b81b8e 100644 --- a/mdl/backend/mock/backend.go +++ b/mdl/backend/mock/backend.go @@ -82,6 +82,7 @@ type MockBackend struct { DeleteMicroflowFunc func(id model.ID) error MoveMicroflowFunc func(mf *microflows.Microflow) error ParseMicroflowFromRawFunc func(raw map[string]any, unitID, containerID model.ID) *microflows.Microflow + ParseMicroflowBSONFunc func(contents []byte, unitID, containerID model.ID) (*microflows.Microflow, error) ListNanoflowsFunc func() ([]*microflows.Nanoflow, error) GetNanoflowFunc func(id model.ID) (*microflows.Nanoflow, error) CreateNanoflowFunc func(nf *microflows.Nanoflow) error diff --git a/mdl/backend/mock/mock_microflow.go b/mdl/backend/mock/mock_microflow.go index f6b77fe7..588cc4cf 100644 --- a/mdl/backend/mock/mock_microflow.go +++ b/mdl/backend/mock/mock_microflow.go @@ -3,6 +3,8 @@ package mock import ( + "fmt" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" ) @@ -56,6 +58,13 @@ func (m *MockBackend) ParseMicroflowFromRaw(raw map[string]any, unitID, containe panic("mock ParseMicroflowFromRaw called but ParseMicroflowFromRawFunc is not set") } +func (m *MockBackend) ParseMicroflowBSON(contents []byte, unitID, containerID model.ID) (*microflows.Microflow, error) { + if m.ParseMicroflowBSONFunc != nil { + return m.ParseMicroflowBSONFunc(contents, unitID, containerID) + } + return nil, fmt.Errorf("MockBackend.ParseMicroflowBSON not configured") +} + func (m *MockBackend) ListNanoflows() ([]*microflows.Nanoflow, error) { if m.ListNanoflowsFunc != nil { return m.ListNanoflowsFunc() diff --git a/mdl/backend/mpr/backend.go b/mdl/backend/mpr/backend.go index 43a7bd10..77dde029 100644 --- a/mdl/backend/mpr/backend.go +++ b/mdl/backend/mpr/backend.go @@ -231,6 +231,9 @@ func (b *MprBackend) ListNanoflows() ([]*microflows.Nanoflow, error) { func (b *MprBackend) ParseMicroflowFromRaw(raw map[string]any, unitID, containerID model.ID) *microflows.Microflow { return mpr.ParseMicroflowFromRaw(raw, unitID, containerID) } +func (b *MprBackend) ParseMicroflowBSON(contents []byte, unitID, containerID model.ID) (*microflows.Microflow, error) { + return mpr.ParseMicroflowBSON(contents, unitID, containerID) +} func (b *MprBackend) GetNanoflow(id model.ID) (*microflows.Nanoflow, error) { return b.reader.GetNanoflow(id) } diff --git a/mdl/executor/bugfix_regression_test.go b/mdl/executor/bugfix_regression_test.go index c0842bbb..3e93f9f6 100644 --- a/mdl/executor/bugfix_regression_test.go +++ b/mdl/executor/bugfix_regression_test.go @@ -9,8 +9,10 @@ import ( "testing" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/microflows" ) @@ -423,3 +425,109 @@ func TestResolveMemberChange_FallbackWithoutReader(t *testing.T) { t.Errorf("expected empty attribute, got %q", mc2.AttributeQualifiedName) } } + +func TestCallMicroflowResultType_ResolvesSubsequentChangeMember(t *testing.T) { + moduleID := model.ID("module-1") + backend := &mock.MockBackend{ + GetModuleByNameFunc: func(name string) (*model.Module, error) { + if name != "MfTest" { + return nil, nil + } + return &model.Module{ + BaseElement: model.BaseElement{ID: moduleID}, + Name: "MfTest", + }, nil + }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { + return []*microflows.Microflow{ + { + ContainerID: moduleID, + Name: "M012_CreateEntity", + ReturnType: µflows.ObjectType{ + EntityQualifiedName: "MfTest.Product", + }, + }, + }, nil + }, + GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { + if id != moduleID { + return nil, nil + } + return &domainmodel.DomainModel{ + ContainerID: moduleID, + Entities: []*domainmodel.Entity{ + { + Name: "Product", + Attributes: []*domainmodel.Attribute{ + { + Name: "Price", + Type: &domainmodel.DecimalAttributeType{}, + }, + }, + }, + }, + }, nil + }, + } + + fb := &flowBuilder{ + varTypes: map[string]string{}, + declaredVars: map[string]string{}, + backend: backend, + } + + fb.addCallMicroflowAction(&ast.CallMicroflowStmt{ + OutputVariable: "Product", + MicroflowName: ast.QualifiedName{Module: "MfTest", Name: "M012_CreateEntity"}, + }) + fb.addChangeObjectAction(&ast.ChangeObjectStmt{ + Variable: "Product", + Changes: []ast.ChangeItem{ + { + Attribute: "Price", + Value: &ast.LiteralExpr{Value: 10.0, Kind: ast.LiteralDecimal}, + }, + }, + }) + + if got := fb.varTypes["Product"]; got != "MfTest.Product" { + t.Fatalf("expected call result type MfTest.Product, got %q", got) + } + + activity, ok := fb.objects[len(fb.objects)-1].(*microflows.ActionActivity) + if !ok { + t.Fatalf("expected last object to be ActionActivity, got %T", fb.objects[len(fb.objects)-1]) + } + changeAction, ok := activity.Action.(*microflows.ChangeObjectAction) + if !ok { + t.Fatalf("expected last action to be ChangeObjectAction, got %T", activity.Action) + } + if len(changeAction.Changes) != 1 { + t.Fatalf("expected one member change, got %d", len(changeAction.Changes)) + } + if got := changeAction.Changes[0].AttributeQualifiedName; got != "MfTest.Product.Price" { + t.Fatalf("expected qualified attribute MfTest.Product.Price, got %q", got) + } +} + +func TestCallMicroflowUnknownResultTypeStillDeclaresVariable(t *testing.T) { + fb := &flowBuilder{ + varTypes: map[string]string{"Result": "Old.ModuleEntity"}, + declaredVars: map[string]string{}, + } + + fb.addCallMicroflowAction(&ast.CallMicroflowStmt{ + OutputVariable: "Result", + MicroflowName: ast.QualifiedName{Module: "Missing", Name: "Unknown"}, + }) + + if _, ok := fb.varTypes["Result"]; ok { + t.Fatalf("expected stale entity typing to be cleared, got %q", fb.varTypes["Result"]) + } + if got := fb.declaredVars["Result"]; got != "Unknown" { + t.Fatalf("expected Result to remain declared as Unknown, got %q", got) + } + if !fb.isVariableDeclared("Result") { + t.Fatal("expected Result to remain declared after unresolved call return type") + } +} diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index acfb1436..0f508393 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -77,6 +77,132 @@ func (fb *flowBuilder) isVariableDeclared(varName string) bool { return false } +// registerResultVariableType records the output type of an action so later +// statements such as CHANGE, ADD TO, or attribute access can resolve members. +// When dt is nil (e.g. backend lookup failed), any stale entity/list typing is +// cleared but the variable remains declared as Unknown so downstream statements +// don't report it as undeclared. +func (fb *flowBuilder) registerResultVariableType(varName string, dt microflows.DataType) { + if varName == "" { + return + } + if dt == nil { + if fb.varTypes != nil { + delete(fb.varTypes, varName) + } + if fb.declaredVars != nil { + fb.declaredVars[varName] = "Unknown" + } + return + } + + switch t := dt.(type) { + case *microflows.ObjectType: + entityQName := t.EntityQualifiedName + if entityQName == "" && t.EntityID != "" { + entityQName = fb.resolveEntityQualifiedName(t.EntityID) + } + if fb.varTypes != nil && entityQName != "" { + fb.varTypes[varName] = entityQName + return + } + case *microflows.ListType: + entityQName := t.EntityQualifiedName + if entityQName == "" && t.EntityID != "" { + entityQName = fb.resolveEntityQualifiedName(t.EntityID) + } + if fb.varTypes != nil && entityQName != "" { + fb.varTypes[varName] = "List of " + entityQName + return + } + } + + if fb.declaredVars != nil { + fb.declaredVars[varName] = dt.GetTypeName() + } +} + +// lookupMicroflowReturnType resolves the return type of a called microflow by +// qualified name so downstream activities can infer variable types. +func (fb *flowBuilder) lookupMicroflowReturnType(qualifiedName string) microflows.DataType { + if fb.backend == nil || qualifiedName == "" { + return nil + } + + if rawUnit, err := fb.backend.GetRawUnitByName("microflow", qualifiedName); err == nil && rawUnit != nil && len(rawUnit.Contents) > 0 { + if mf, err := fb.backend.ParseMicroflowBSON(rawUnit.Contents, model.ID(rawUnit.ID), ""); err == nil && mf != nil { + return mf.ReturnType + } + } + + moduleName, microflowName, ok := strings.Cut(qualifiedName, ".") + if !ok || moduleName == "" || microflowName == "" { + return nil + } + + module, err := fb.backend.GetModuleByName(moduleName) + if err != nil || module == nil { + return nil + } + microflowList, err := fb.backend.ListMicroflows() + if err != nil { + return nil + } + + for _, mf := range microflowList { + if mf == nil { + continue + } + containerModuleID := mf.ContainerID + if fb.hierarchy != nil { + containerModuleID = fb.hierarchy.FindModuleID(mf.ContainerID) + } + if containerModuleID == module.ID && mf.Name == microflowName { + return mf.ReturnType + } + } + + return nil +} + +func (fb *flowBuilder) resolveEntityQualifiedName(entityID model.ID) string { + if fb.backend == nil || entityID == "" { + return "" + } + + domainModels, err := fb.backend.ListDomainModels() + if err != nil { + return "" + } + + for _, dm := range domainModels { + if dm == nil { + continue + } + + moduleName := "" + if fb.hierarchy != nil { + moduleName = fb.hierarchy.GetModuleName(dm.ContainerID) + } + if moduleName == "" { + if mod, err := fb.backend.GetModule(dm.ContainerID); err == nil && mod != nil { + moduleName = mod.Name + } + } + if moduleName == "" { + continue + } + + for _, entity := range dm.Entities { + if entity != nil && entity.ID == entityID { + return moduleName + "." + entity.Name + } + } + } + + return "" +} + // exprToString converts an AST Expression to a Mendix expression string, // resolving association navigation paths to include the target entity qualifier. // e.g. $Order/MyModule.Order_Customer/Name → $Order/MyModule.Order_Customer/MyModule.Customer/Name diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index 1535a55c..5482c032 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -139,6 +139,10 @@ func (fb *flowBuilder) addCallMicroflowAction(s *ast.CallMicroflowStmt) model.ID fb.objects = append(fb.objects, activity) fb.posX += fb.spacing + if s.OutputVariable != "" { + fb.registerResultVariableType(s.OutputVariable, fb.lookupMicroflowReturnType(mfQN)) + } + // Build custom error handler flow if present if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { errorY := fb.posY + VerticalSpacing diff --git a/mdl/executor/cmd_microflows_builder_validate.go b/mdl/executor/cmd_microflows_builder_validate.go index 5147cfa2..5ac0f12d 100644 --- a/mdl/executor/cmd_microflows_builder_validate.go +++ b/mdl/executor/cmd_microflows_builder_validate.go @@ -125,8 +125,13 @@ func (fb *flowBuilder) validateStatement(stmt ast.MicroflowStatement) { case *ast.CallMicroflowStmt: // Register result variable if assigned if s.OutputVariable != "" { - // We don't know the return type, so just mark it as declared - fb.declaredVars[s.OutputVariable] = "Unknown" + mfQN := s.MicroflowName.Module + "." + s.MicroflowName.Name + if returnType := fb.lookupMicroflowReturnType(mfQN); returnType != nil { + fb.registerResultVariableType(s.OutputVariable, returnType) + } else { + // We don't know the return type, so just mark it as declared + fb.declaredVars[s.OutputVariable] = "Unknown" + } } // Validate error handler body if present if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { diff --git a/mdl/executor/cmd_microflows_create.go b/mdl/executor/cmd_microflows_create.go index 807c1036..7459dbac 100644 --- a/mdl/executor/cmd_microflows_create.go +++ b/mdl/executor/cmd_microflows_create.go @@ -115,6 +115,8 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { } if preserveAllowedRoles { mf.AllowedModuleRoles = existingAllowedRoles + } else { + mf.AllowedModuleRoles = defaultDocumentAccessRoles(ctx, module) } // Build entity resolver function for parameter/return types diff --git a/mdl/executor/cmd_modules.go b/mdl/executor/cmd_modules.go index c58e0fb8..4a156bcd 100644 --- a/mdl/executor/cmd_modules.go +++ b/mdl/executor/cmd_modules.go @@ -276,6 +276,9 @@ func execDropModule(ctx *ExecContext, s *ast.DropModuleStmt) error { fmt.Fprintf(ctx.Output, "Removed %s from %d user role(s)\n", qualifiedRole, n) } } + if err := pruneInvalidUserRoles(ctx, ps); err != nil { + return mdlerrors.NewBackend("cleanup invalid user roles", err) + } } } diff --git a/mdl/executor/cmd_move.go b/mdl/executor/cmd_move.go index 8183b14b..28e2d408 100644 --- a/mdl/executor/cmd_move.go +++ b/mdl/executor/cmd_move.go @@ -56,11 +56,11 @@ func execMove(ctx *ExecContext, s *ast.MoveStmt) error { // Execute move based on document type switch s.DocumentType { case ast.DocumentTypePage: - if err := movePage(ctx, s.Name, targetContainerID); err != nil { + if err := movePage(ctx, s.Name, targetContainerID, targetModule, isCrossModuleMove); err != nil { return err } case ast.DocumentTypeMicroflow: - if err := moveMicroflow(ctx, s.Name, targetContainerID); err != nil { + if err := moveMicroflow(ctx, s.Name, targetContainerID, targetModule, isCrossModuleMove); err != nil { return err } case ast.DocumentTypeSnippet: @@ -110,7 +110,7 @@ func updateQualifiedNameRefs(ctx *ExecContext, name ast.QualifiedName, newModule } // movePage moves a page to a new container. -func movePage(ctx *ExecContext, name ast.QualifiedName, targetContainerID model.ID) error { +func movePage(ctx *ExecContext, name ast.QualifiedName, targetContainerID model.ID, targetModule *model.Module, isCrossModuleMove bool) error { // Find the page pages, err := ctx.Backend.ListPages() if err != nil { @@ -131,6 +131,12 @@ func movePage(ctx *ExecContext, name ast.QualifiedName, targetContainerID model. if err := ctx.Backend.MovePage(p); err != nil { return mdlerrors.NewBackend("move page", err) } + if isCrossModuleMove { + p.AllowedRoles = remapDocumentAccessRoles(ctx, targetModule, p.AllowedRoles) + if err := ctx.Backend.UpdateAllowedRoles(p.ID, documentRoleStrings(p.AllowedRoles)); err != nil { + return mdlerrors.NewBackend("remap page access", err) + } + } fmt.Fprintf(ctx.Output, "Moved page %s to new location\n", name.String()) return nil } @@ -140,7 +146,7 @@ func movePage(ctx *ExecContext, name ast.QualifiedName, targetContainerID model. } // moveMicroflow moves a microflow to a new container. -func moveMicroflow(ctx *ExecContext, name ast.QualifiedName, targetContainerID model.ID) error { +func moveMicroflow(ctx *ExecContext, name ast.QualifiedName, targetContainerID model.ID, targetModule *model.Module, isCrossModuleMove bool) error { // Find the microflow mfs, err := ctx.Backend.ListMicroflows() if err != nil { @@ -161,6 +167,12 @@ func moveMicroflow(ctx *ExecContext, name ast.QualifiedName, targetContainerID m if err := ctx.Backend.MoveMicroflow(mf); err != nil { return mdlerrors.NewBackend("move microflow", err) } + if isCrossModuleMove { + mf.AllowedModuleRoles = remapDocumentAccessRoles(ctx, targetModule, mf.AllowedModuleRoles) + if err := ctx.Backend.UpdateAllowedRoles(mf.ID, documentRoleStrings(mf.AllowedModuleRoles)); err != nil { + return mdlerrors.NewBackend("remap microflow access", err) + } + } fmt.Fprintf(ctx.Output, "Moved microflow %s to new location\n", name.String()) return nil } diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index fb558074..6deff9d6 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -1496,38 +1496,38 @@ func fetchODataMetadata(metadataUrl string) (metadata string, hash string, err e return "", "", nil } - var body []byte - - // At this point, metadataUrl is already normalized by NormalizeURL() in createODataClient: - // - Relative paths have been converted to absolute file:// URLs - // - HTTP(S) URLs are unchanged - // So we only need to distinguish file:// vs HTTP(S) - - filePath := pathutil.PathFromURL(metadataUrl) - if filePath != "" { - // Local file - read directly (path is already absolute) - body, err = os.ReadFile(filePath) - if err != nil { - return "", "", mdlerrors.NewBackend(fmt.Sprintf("read local metadata file %s", filePath), err) - } - } else { - // HTTP(S) fetch - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Get(metadataUrl) - if err != nil { - return "", "", mdlerrors.NewBackend(fmt.Sprintf("fetch $metadata from %s", metadataUrl), err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", "", mdlerrors.NewValidationf("$metadata fetch returned HTTP %d from %s", resp.StatusCode, metadataUrl) - } - - body, err = io.ReadAll(resp.Body) - if err != nil { - return "", "", mdlerrors.NewBackend("read $metadata response", err) - } - } + var body []byte + + // At this point, metadataUrl is already normalized by NormalizeURL() in createODataClient: + // - Relative paths have been converted to absolute file:// URLs + // - HTTP(S) URLs are unchanged + // So we only need to distinguish file:// vs HTTP(S) + + filePath := pathutil.PathFromURL(metadataUrl) + if filePath != "" { + // Local file - read directly (path is already absolute) + body, err = os.ReadFile(filePath) + if err != nil { + return "", "", mdlerrors.NewBackend(fmt.Sprintf("read local metadata file %s", filePath), err) + } + } else { + // HTTP(S) fetch + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(metadataUrl) + if err != nil { + return "", "", mdlerrors.NewBackend(fmt.Sprintf("fetch $metadata from %s", metadataUrl), err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", mdlerrors.NewValidationf("$metadata fetch returned HTTP %d from %s", resp.StatusCode, metadataUrl) + } + + body, err = io.ReadAll(resp.Body) + if err != nil { + return "", "", mdlerrors.NewBackend("read $metadata response", err) + } + } // Hash calculation (same for both HTTP and local file) metadata = string(body) diff --git a/mdl/executor/cmd_pages_create_v3.go b/mdl/executor/cmd_pages_create_v3.go index c868f48e..9f8ed6eb 100644 --- a/mdl/executor/cmd_pages_create_v3.go +++ b/mdl/executor/cmd_pages_create_v3.go @@ -40,6 +40,8 @@ func execCreatePageV3(ctx *ExecContext, s *ast.CreatePageStmtV3) error { // Check if page already exists - collect ALL duplicates existingPages, _ := ctx.Backend.ListPages() var pagesToDelete []model.ID + var existingAllowedRoles []model.ID + preserveAllowedRoles := false for _, p := range existingPages { modID := getModuleID(ctx, p.ContainerID) modName := getModuleName(ctx, modID) @@ -47,6 +49,10 @@ func execCreatePageV3(ctx *ExecContext, s *ast.CreatePageStmtV3) error { if !s.IsReplace && !s.IsModify && len(pagesToDelete) == 0 { return mdlerrors.NewAlreadyExists("page", s.Name.String()) } + if len(pagesToDelete) == 0 { + existingAllowedRoles = cloneRoleIDs(p.AllowedRoles) + preserveAllowedRoles = true + } pagesToDelete = append(pagesToDelete, p.ID) } } @@ -69,6 +75,11 @@ func execCreatePageV3(ctx *ExecContext, s *ast.CreatePageStmtV3) error { if err != nil { return mdlerrors.NewBackend("build page", err) } + if preserveAllowedRoles { + page.AllowedRoles = existingAllowedRoles + } else if len(page.AllowedRoles) == 0 { + page.AllowedRoles = defaultDocumentAccessRoles(ctx, module) + } // Replace or create the page in the MPR if len(pagesToDelete) > 0 { diff --git a/mdl/executor/cmd_security_defaults.go b/mdl/executor/cmd_security_defaults.go new file mode 100644 index 00000000..4659e99f --- /dev/null +++ b/mdl/executor/cmd_security_defaults.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/security" +) + +const ( + autoDocumentRoleName = "User" + autoDocumentRoleDescription = "Auto-created default role for mxcli document access" +) + +// defaultDocumentAccessRoles returns a conservative fallback role set for newly +// created pages/microflows when the target module has no module roles at all. +// +// Mendix accepts document access only when it references a role from the same +// module; using an existing role from another module causes CE0148 on freshly +// created documents. To keep mx-check green, auto-create a local `User` module +// role only for modules that currently have zero roles. Modules that already +// manage their own roles keep the existing "no access by default" behavior. +func defaultDocumentAccessRoles(ctx *ExecContext, module *model.Module) []model.ID { + if module == nil { + return nil + } + + ms, err := ctx.Backend.GetModuleSecurity(module.ID) + if err != nil || ms == nil { + return nil + } + if moduleUsesAutoDocumentRole(ms) { + return []model.ID{model.ID(module.Name + "." + autoDocumentRoleName)} + } + if len(ms.ModuleRoles) > 0 { + return nil + } + + if err := ctx.Backend.AddModuleRole(ms.ID, autoDocumentRoleName, autoDocumentRoleDescription); err != nil { + return nil + } + return []model.ID{model.ID(module.Name + "." + autoDocumentRoleName)} +} + +func moduleUsesAutoDocumentRole(ms *security.ModuleSecurity) bool { + if ms == nil { + return false + } + return len(ms.ModuleRoles) == 1 && + ms.ModuleRoles[0].Name == autoDocumentRoleName && + ms.ModuleRoles[0].Description == autoDocumentRoleDescription +} + +func remapDocumentAccessRoles(ctx *ExecContext, targetModule *model.Module, currentRoles []model.ID) []model.ID { + if targetModule == nil { + return nil + } + + ms, err := ctx.Backend.GetModuleSecurity(targetModule.ID) + if err != nil || ms == nil { + return nil + } + if len(ms.ModuleRoles) == 0 || moduleUsesAutoDocumentRole(ms) { + return defaultDocumentAccessRoles(ctx, targetModule) + } + + targetRoleNames := make(map[string]bool, len(ms.ModuleRoles)) + for _, role := range ms.ModuleRoles { + targetRoleNames[role.Name] = true + } + + var remapped []model.ID + seen := make(map[string]bool) + for _, qualifiedRole := range currentRoles { + roleName := string(qualifiedRole) + if idx := strings.LastIndex(roleName, "."); idx >= 0 { + roleName = roleName[idx+1:] + } + if !targetRoleNames[roleName] { + continue + } + targetQualifiedRole := targetModule.Name + "." + roleName + if seen[targetQualifiedRole] { + continue + } + seen[targetQualifiedRole] = true + remapped = append(remapped, model.ID(targetQualifiedRole)) + } + + return remapped +} + +func documentRoleStrings(roles []model.ID) []string { + values := make([]string, 0, len(roles)) + for _, role := range roles { + values = append(values, string(role)) + } + return values +} + +func cloneRoleIDs(roles []model.ID) []model.ID { + if len(roles) == 0 { + return nil + } + cloned := make([]model.ID, len(roles)) + copy(cloned, roles) + return cloned +} + +// pruneInvalidUserRoles removes user roles that no longer have any non-System +// module role assignments. Mendix rejects those roles with CE0157. +func pruneInvalidUserRoles(ctx *ExecContext, ps *security.ProjectSecurity) error { + if latest, err := ctx.Backend.GetProjectSecurity(); err == nil { + ps = latest + } else if ps == nil { + return err + } + + for _, userRole := range ps.UserRoles { + hasNonSystemRole := false + for _, moduleRole := range userRole.ModuleRoles { + if !strings.HasPrefix(moduleRole, "System.") { + hasNonSystemRole = true + break + } + } + if hasNonSystemRole { + continue + } + if err := ctx.Backend.RemoveUserRole(ps.ID, userRole.Name); err != nil { + return err + } + if !ctx.Quiet { + fmt.Fprintf(ctx.Output, "Dropped invalid user role: %s\n", userRole.Name) + } + } + + return nil +} diff --git a/mdl/executor/cmd_security_write.go b/mdl/executor/cmd_security_write.go index fb148020..3fd73bc2 100644 --- a/mdl/executor/cmd_security_write.go +++ b/mdl/executor/cmd_security_write.go @@ -31,11 +31,35 @@ func execCreateModuleRole(ctx *ExecContext, s *ast.CreateModuleRoleStmt) error { return mdlerrors.NewBackend(fmt.Sprintf("read module security for %s", s.Name.Module), err) } - // Check if role already exists + // Check if role already exists. Mendix treats role names case-insensitively + // (CE0123), so match that way. An auto-provisioned role collides with any + // user-requested casing; let AddModuleRole overwrite to adopt the caller's + // casing so later case-sensitive lookups (GRANT ACCESS TO x.user) succeed. for _, mr := range ms.ModuleRoles { - if mr.Name == s.Name.Name { - return mdlerrors.NewAlreadyExists("module role", s.Name.Module+"."+s.Name.Name) + if !strings.EqualFold(mr.Name, s.Name.Name) { + continue } + if mr.Description == autoDocumentRoleDescription { + oldQualified := s.Name.Module + "." + mr.Name + newQualified := s.Name.Module + "." + s.Name.Name + if err := ctx.Backend.AddModuleRole(ms.ID, s.Name.Name, s.Description); err != nil { + return mdlerrors.NewBackend("create module role", err) + } + // If the casing actually changed, propagate the rename across every + // unit that referenced the old name (AllowedModuleRoles on microflows, + // pages, published REST services, etc.). Without this, mx check fails + // with CE1613 "selected module role X no longer exists". + if oldQualified != newQualified { + if _, err := ctx.Backend.UpdateQualifiedNameInAllUnits(oldQualified, newQualified); err != nil { + return mdlerrors.NewBackend(fmt.Sprintf("rename references %s -> %s", oldQualified, newQualified), err) + } + } + if !ctx.Quiet { + fmt.Fprintf(ctx.Output, "Module role %s.%s already exists (auto-provisioned)\n", s.Name.Module, s.Name.Name) + } + return nil + } + return mdlerrors.NewAlreadyExists("module role", s.Name.Module+"."+s.Name.Name) } if err := ctx.Backend.AddModuleRole(ms.ID, s.Name.Name, s.Description); err != nil { @@ -149,6 +173,9 @@ func execDropModuleRole(ctx *ExecContext, s *ast.DropModuleRoleStmt) error { if n, err := ctx.Backend.RemoveModuleRoleFromAllUserRoles(ps.ID, qualifiedRole); err == nil && n > 0 { fmt.Fprintf(ctx.Output, "Removed %s from %d user role(s)\n", qualifiedRole, n) } + if err := pruneInvalidUserRoles(ctx, ps); err != nil { + return mdlerrors.NewBackend("cleanup invalid user roles", err) + } } // Finally, remove the role itself diff --git a/mdl/executor/executor.go b/mdl/executor/executor.go index 85fe76cc..878f3c51 100644 --- a/mdl/executor/executor.go +++ b/mdl/executor/executor.go @@ -461,15 +461,6 @@ func rememberDroppedMicroflow(ctx *ExecContext, qualifiedName string, id, contai } } -func cloneRoleIDs(roles []model.ID) []model.ID { - if len(roles) == 0 { - return nil - } - cloned := make([]model.ID, len(roles)) - copy(cloned, roles) - return cloned -} - // consumeDroppedMicroflow returns the original IDs of a microflow dropped // earlier in this session (if any) and removes the entry so repeated CREATEs // don't collide on the same ID. Returns nil when nothing was remembered. diff --git a/mdl/executor/roundtrip_doctype_test.go b/mdl/executor/roundtrip_doctype_test.go index 05c8b1a6..d3bf3a7e 100644 --- a/mdl/executor/roundtrip_doctype_test.go +++ b/mdl/executor/roundtrip_doctype_test.go @@ -39,7 +39,6 @@ var scriptKnownCEErrors = map[string][]string{ "06-rest-client-examples.mdl": { "CE0061", // No entity selected (JSON response/body mapping without entity) "CE6035", // RestOperationCallAction error handling not supported - "CE6702", // TODO: export mapping root ObjectHandling not persisted correctly "CE7056", // Undefined parameter (dynamic header {1} placeholder) "CE7062", // Missing Accept header "CE7064", // POST/PUT must include body diff --git a/mdl/executor/roundtrip_helpers_mx_test.go b/mdl/executor/roundtrip_helpers_mx_test.go new file mode 100644 index 00000000..4ba6c34e --- /dev/null +++ b/mdl/executor/roundtrip_helpers_mx_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package executor + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" +) + +func TestNewestVersionedPath_PicksNewestNumericVersion(t *testing.T) { + t.Parallel() + + paths := []string{ + "/tmp/.mxcli/mxbuild/11.9.0/modeler/mx", + "/tmp/.mxcli/mxbuild/11.6.3/modeler/mx", + "/tmp/.mxcli/mxbuild/9.24.40.80973/modeler/mx", + } + + got := docker.NewestVersionedPath(paths) + want := "/tmp/.mxcli/mxbuild/11.9.0/modeler/mx" + if got != want { + t.Fatalf("docker.NewestVersionedPath() = %q, want %q", got, want) + } +} + +func TestFindMxBinary_PrefersPathOverCachedDownloads(t *testing.T) { + home := t.TempDir() + pathDir := filepath.Join(home, "bin") + if err := os.MkdirAll(pathDir, 0755); err != nil { + t.Fatalf("mkdir path dir: %v", err) + } + + pathMx := filepath.Join(pathDir, "mx") + if err := os.WriteFile(pathMx, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { + t.Fatalf("write PATH mx: %v", err) + } + + for _, version := range []string{"11.9.0", "11.6.3", "9.24.40.80973"} { + cacheMx := filepath.Join(home, ".mxcli", "mxbuild", version, "modeler", "mx") + if err := os.MkdirAll(filepath.Dir(cacheMx), 0755); err != nil { + t.Fatalf("mkdir cache dir: %v", err) + } + if err := os.WriteFile(cacheMx, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { + t.Fatalf("write cached mx: %v", err) + } + } + + t.Setenv("HOME", home) + t.Setenv("PATH", pathDir) + t.Setenv("MX_BINARY", "") + + if got := findMxBinary(); got != pathMx { + t.Fatalf("findMxBinary() = %q, want PATH binary %q", got, pathMx) + } +} diff --git a/mdl/executor/roundtrip_helpers_test.go b/mdl/executor/roundtrip_helpers_test.go index 3a9906a8..f7b1b25e 100644 --- a/mdl/executor/roundtrip_helpers_test.go +++ b/mdl/executor/roundtrip_helpers_test.go @@ -21,6 +21,7 @@ import ( "strings" "testing" + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/backend" mprbackend "github.com/mendixlabs/mxcli/mdl/backend/mpr" @@ -132,8 +133,8 @@ func copyTestProject(t *testing.T) string { } // findMxBinary searches for the mx command in known locations. -// Search order: MX_BINARY env var, reference/mxbuild/modeler/mx (repo-local), -// ~/.mxcli/mxbuild/*/modeler/mx (cached downloads), PATH lookup. +// Search order: MX_BINARY env var, PATH lookup, reference/mxbuild/modeler/mx +// (repo-local), ~/.mxcli/mxbuild/*/modeler/mx (cached downloads, newest numeric version). func findMxBinary() string { // 0. Explicit override via environment variable if p := os.Getenv("MX_BINARY"); p != "" { @@ -142,7 +143,12 @@ func findMxBinary() string { } } - // 1. Repo-local reference path + // 1. PATH lookup + if p, err := exec.LookPath("mx"); err == nil { + return p + } + + // 2. Repo-local reference path repoPath, err := filepath.Abs("../../reference/mxbuild/modeler/mx") if err == nil { if _, err := os.Stat(repoPath); err == nil { @@ -150,22 +156,22 @@ func findMxBinary() string { } } - // 2. Cached downloads (~/.mxcli/mxbuild/*/modeler/mx) + // 3. Cached downloads (~/.mxcli/mxbuild/*/modeler/mx) if home, err := os.UserHomeDir(); err == nil { pattern := filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", "mx") if matches, _ := filepath.Glob(pattern); len(matches) > 0 { - return matches[len(matches)-1] + return docker.NewestVersionedPath(matches) } } - // 3. PATH lookup - if p, err := exec.LookPath("mx"); err == nil { - return p - } - return "" } +// newestVersionedPath / versionFromPath / parseVersionParts / compareVersionParts +// used to be duplicated here. They now live as exported helpers in +// cmd/mxcli/docker (docker.NewestVersionedPath). The integration-test harness +// call-site was adjusted to use the exported helper instead. + // copyFile copies a single file from src to dst. func copyFile(src, dst string) error { in, err := os.Open(src) diff --git a/sdk/mpr/parser_import_mapping.go b/sdk/mpr/parser_import_mapping.go index 8378988a..c9216ff9 100644 --- a/sdk/mpr/parser_import_mapping.go +++ b/sdk/mpr/parser_import_mapping.go @@ -96,6 +96,11 @@ func parseImportObjectMappingElement(raw map[string]any) *model.ImportMappingEle } if v, ok := raw["ObjectHandling"].(string); ok { elem.ObjectHandling = v + if v == "Find" { + if backup, ok := raw["ObjectHandlingBackup"].(string); ok && backup == "Create" { + elem.ObjectHandling = "FindOrCreate" + } + } } if v, ok := raw["Association"].(string); ok { elem.Association = v diff --git a/sdk/mpr/parser_import_mapping_test.go b/sdk/mpr/parser_import_mapping_test.go new file mode 100644 index 00000000..9a054a5b --- /dev/null +++ b/sdk/mpr/parser_import_mapping_test.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import "testing" + +func TestParseImportObjectMappingElement_FindWithCreateBackupBecomesFindOrCreate(t *testing.T) { + elem := parseImportObjectMappingElement(map[string]any{ + "$ID": "ignored", + "$Type": "ImportMappings$ObjectMappingElement", + "Entity": "MyModule.Pet", + "ObjectHandling": "Find", + "ObjectHandlingBackup": "Create", + }) + + if elem == nil { + t.Fatal("expected element, got nil") + } + if elem.ObjectHandling != "FindOrCreate" { + t.Fatalf("ObjectHandling = %q, want %q", elem.ObjectHandling, "FindOrCreate") + } +} diff --git a/sdk/mpr/writer_export_mapping.go b/sdk/mpr/writer_export_mapping.go index c2d8da17..ed99e392 100644 --- a/sdk/mpr/writer_export_mapping.go +++ b/sdk/mpr/writer_export_mapping.go @@ -133,7 +133,7 @@ func serializeExportObjectElement(id string, elem *model.ExportMappingElement, p "JsonPath": jsonPath, "XmlPath": "", "ObjectHandling": objectHandling, - "ObjectHandlingBackup": "Create", + "ObjectHandlingBackup": objectHandling, "ObjectHandlingBackupAllowOverride": false, "Association": elem.Association, "Children": children, diff --git a/sdk/mpr/writer_export_mapping_test.go b/sdk/mpr/writer_export_mapping_test.go index 64976d25..43162ee2 100644 --- a/sdk/mpr/writer_export_mapping_test.go +++ b/sdk/mpr/writer_export_mapping_test.go @@ -79,6 +79,8 @@ func TestSerializeExportMapping_TypeNames(t *testing.T) { // CRITICAL: must NOT be "ExportMappings$ExportObjectMappingElement" assertField(t, objElem, "$Type", "ExportMappings$ObjectMappingElement") assertField(t, objElem, "Entity", "MyModule.Pet") + assertField(t, objElem, "ObjectHandling", "Parameter") + assertField(t, objElem, "ObjectHandlingBackup", "Parameter") children := extractBsonArray(objElem["Children"]) if len(children) != 2 { diff --git a/sdk/mpr/writer_import_mapping.go b/sdk/mpr/writer_import_mapping.go index 92f08bdb..fd84573c 100644 --- a/sdk/mpr/writer_import_mapping.go +++ b/sdk/mpr/writer_import_mapping.go @@ -119,6 +119,11 @@ func serializeImportObjectElement(id string, elem *model.ImportMappingElement, p if objectHandling == "" { objectHandling = "Create" } + objectHandlingBackup := objectHandling + if objectHandling == "FindOrCreate" { + objectHandling = "Find" + objectHandlingBackup = "Create" + } // IMPORTANT: The correct $Type is "ImportMappings$ObjectMappingElement" (no "Import" prefix in the element name). // The generated metamodel (ImportMappingsImportObjectMappingElement) is misleading — Studio Pro will throw @@ -132,7 +137,7 @@ func serializeImportObjectElement(id string, elem *model.ImportMappingElement, p "JsonPath": jsonPath, "XmlPath": "", "ObjectHandling": objectHandling, - "ObjectHandlingBackup": objectHandling, + "ObjectHandlingBackup": objectHandlingBackup, "ObjectHandlingBackupAllowOverride": false, "Association": elem.Association, "Children": children, diff --git a/sdk/mpr/writer_import_mapping_test.go b/sdk/mpr/writer_import_mapping_test.go index 651b4d62..a5717639 100644 --- a/sdk/mpr/writer_import_mapping_test.go +++ b/sdk/mpr/writer_import_mapping_test.go @@ -181,6 +181,45 @@ func TestSerializeImportMapping_WithJsonStructureRef(t *testing.T) { assertField(t, raw, "JsonStructure", "MyModule.PetJsonStructure") } +func TestSerializeImportMapping_FindOrCreateUsesFindWithCreateBackup(t *testing.T) { + w := &Writer{} + im := &model.ImportMapping{ + BaseElement: model.BaseElement{ID: "test-im-upsert"}, + ContainerID: "test-module-id", + Name: "UpsertMapping", + Elements: []*model.ImportMappingElement{ + { + BaseElement: model.BaseElement{ID: "root-id"}, + Kind: "Object", + Entity: "MyModule.Pet", + ObjectHandling: "FindOrCreate", + }, + }, + } + + data, err := w.serializeImportMapping(im) + if err != nil { + t.Fatalf("serializeImportMapping: %v", err) + } + + var raw map[string]any + if err := bson.Unmarshal(data, &raw); err != nil { + t.Fatalf("bson.Unmarshal: %v", err) + } + + elems := extractBsonArray(raw["Elements"]) + if len(elems) != 1 { + t.Fatalf("Elements: expected 1, got %d", len(elems)) + } + + objElem, ok := elems[0].(map[string]any) + if !ok { + t.Fatalf("Elements[0]: expected map, got %T", elems[0]) + } + assertField(t, objElem, "ObjectHandling", "Find") + assertField(t, objElem, "ObjectHandlingBackup", "Create") +} + // TestSerializeImportValueDataType_AllTypes verifies that all supported data types // map to the correct DataTypes$* BSON $Type values. func TestSerializeImportValueDataType_AllTypes(t *testing.T) { diff --git a/sdk/mpr/writer_security.go b/sdk/mpr/writer_security.go index 672fd194..c4941003 100644 --- a/sdk/mpr/writer_security.go +++ b/sdk/mpr/writer_security.go @@ -4,6 +4,7 @@ package mpr import ( "fmt" + "strings" "github.com/mendixlabs/mxcli/model" @@ -170,8 +171,53 @@ func (w *Writer) RemoveFromAllowedRoles(unitID model.ID, roleName string) (bool, // ============================================================================ // AddModuleRole adds a new module role to the module's Security$ModuleSecurity unit. +// If a role with the same name (case-insensitive) already exists, the existing role's +// Name is overwritten with the caller-supplied casing and Description is updated. +// Mendix Studio Pro rejects case-insensitive duplicate role names with CE0123, so +// merging into the existing entry matches runtime semantics — and preserves the +// caller's casing for downstream case-sensitive lookups (e.g., GRANT ACCESS TO x.user). func (w *Writer) AddModuleRole(unitID model.ID, roleName, description string) error { return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { + // Get existing ModuleRoles array + existing := getBsonArray(doc, "ModuleRoles") + if existing == nil { + existing = bson.A{int32(1)} + } + + // If a case-insensitive duplicate already exists, overwrite its Name and + // Description with the caller's values. This keeps the ID stable (any + // references to it remain valid) while adopting the newly-requested casing. + for i, item := range existing { + role, ok := item.(bson.D) + if !ok { + continue + } + matched := false + for _, field := range role { + if field.Key == "Name" { + if name, ok := field.Value.(string); ok && strings.EqualFold(name, roleName) { + matched = true + } + break + } + } + if !matched { + continue + } + for j, field := range role { + switch field.Key { + case "Name": + role[j].Value = roleName + case "Description": + if description != "" { + role[j].Value = description + } + } + } + existing[i] = role + return setBsonField(doc, "ModuleRoles", existing), nil + } + // Build the new role BSON document newRole := bson.D{ {Key: "$Type", Value: "Security$ModuleRole"}, @@ -180,12 +226,6 @@ func (w *Writer) AddModuleRole(unitID model.ID, roleName, description string) er {Key: "Description", Value: description}, } - // Get existing ModuleRoles array - existing := getBsonArray(doc, "ModuleRoles") - if existing == nil { - existing = bson.A{int32(1)} - } - existing = append(existing, newRole) return setBsonField(doc, "ModuleRoles", existing), nil })