Skip to content

Commit 14141fc

Browse files
hjothaclaude
authored andcommitted
fix: align role casing and unstick integration roundtrip tests
Addresses the integration-test failures that the fork-sync branch exposed: - sdk/mpr/writer_security.go: AddModuleRole now overwrites the existing role's Name/Description when a case-insensitive duplicate is detected. Mendix rejects case-insensitive duplicate role names (CE0123), so adopting the caller's casing matches runtime semantics and keeps subsequent GRANT ACCESS lookups resolvable. - mdl/executor/cmd_security_write.go: execCreateModuleRole matches the existing role case-insensitively and propagates a casing change to all units via UpdateQualifiedNameInAllUnits. Without this, AllowedModuleRoles references on microflows/pages/published REST services that were created before the user-declared role ran would be stale (CE1613). - mdl-examples/doctype-tests/06-rest-client-examples.mdl: capitalize Status = status in the PetRecord find-or-create block; attribute references are case-sensitive. - mdl-examples/doctype-tests/workflow-user-targeting.mdl: replace the obsolete System.UserGroup with System.WorkflowGroup to match the Mendix 11.9 workflow metamodel (CE5012). - mdl/executor/roundtrip_microflow_test.go: compare LOG INFO NODE expected output case-insensitively. DESCRIBE now emits lowercase keywords (commit 00b80f3). - mdl/executor/cmd_odata.go: pick up the gofmt-clean tab indentation after make lint-go; purely whitespace. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c59da48 commit 14141fc

5 files changed

Lines changed: 106 additions & 48 deletions

File tree

mdl-examples/doctype-tests/06-rest-client-examples.mdl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1314,7 +1314,7 @@ create import mapping RestTest.IMM_UpsertPet
13141314
find or create RestTest.PetRecord {
13151315
PetId = id key,
13161316
Name = name,
1317-
status = status
1317+
Status = status
13181318
}
13191319
};
13201320

mdl-examples/doctype-tests/workflow-user-targeting.mdl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ CREATE MICROFLOW WFTarget.ACT_GetGroups (
3535
$Workflow: System.Workflow,
3636
$Context: WFTarget.Request
3737
)
38-
RETURNS List of System.UserGroup AS $Groups
38+
RETURNS List of System.WorkflowGroup AS $Groups
3939
BEGIN
4040
@position(200,200)
41-
RETRIEVE $Groups FROM System.UserGroup;
41+
RETRIEVE $Groups FROM System.WorkflowGroup;
4242
@position(400,200) RETURN $Groups;
4343
END;
4444
/

mdl/executor/cmd_odata.go

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,38 +1496,38 @@ func fetchODataMetadata(metadataUrl string) (metadata string, hash string, err e
14961496
return "", "", nil
14971497
}
14981498

1499-
var body []byte
1500-
1501-
// At this point, metadataUrl is already normalized by NormalizeURL() in createODataClient:
1502-
// - Relative paths have been converted to absolute file:// URLs
1503-
// - HTTP(S) URLs are unchanged
1504-
// So we only need to distinguish file:// vs HTTP(S)
1505-
1506-
filePath := pathutil.PathFromURL(metadataUrl)
1507-
if filePath != "" {
1508-
// Local file - read directly (path is already absolute)
1509-
body, err = os.ReadFile(filePath)
1510-
if err != nil {
1511-
return "", "", mdlerrors.NewBackend(fmt.Sprintf("read local metadata file %s", filePath), err)
1512-
}
1513-
} else {
1514-
// HTTP(S) fetch
1515-
client := &http.Client{Timeout: 30 * time.Second}
1516-
resp, err := client.Get(metadataUrl)
1517-
if err != nil {
1518-
return "", "", mdlerrors.NewBackend(fmt.Sprintf("fetch $metadata from %s", metadataUrl), err)
1519-
}
1520-
defer resp.Body.Close()
1521-
1522-
if resp.StatusCode != http.StatusOK {
1523-
return "", "", mdlerrors.NewValidationf("$metadata fetch returned HTTP %d from %s", resp.StatusCode, metadataUrl)
1524-
}
1525-
1526-
body, err = io.ReadAll(resp.Body)
1527-
if err != nil {
1528-
return "", "", mdlerrors.NewBackend("read $metadata response", err)
1529-
}
1530-
}
1499+
var body []byte
1500+
1501+
// At this point, metadataUrl is already normalized by NormalizeURL() in createODataClient:
1502+
// - Relative paths have been converted to absolute file:// URLs
1503+
// - HTTP(S) URLs are unchanged
1504+
// So we only need to distinguish file:// vs HTTP(S)
1505+
1506+
filePath := pathutil.PathFromURL(metadataUrl)
1507+
if filePath != "" {
1508+
// Local file - read directly (path is already absolute)
1509+
body, err = os.ReadFile(filePath)
1510+
if err != nil {
1511+
return "", "", mdlerrors.NewBackend(fmt.Sprintf("read local metadata file %s", filePath), err)
1512+
}
1513+
} else {
1514+
// HTTP(S) fetch
1515+
client := &http.Client{Timeout: 30 * time.Second}
1516+
resp, err := client.Get(metadataUrl)
1517+
if err != nil {
1518+
return "", "", mdlerrors.NewBackend(fmt.Sprintf("fetch $metadata from %s", metadataUrl), err)
1519+
}
1520+
defer resp.Body.Close()
1521+
1522+
if resp.StatusCode != http.StatusOK {
1523+
return "", "", mdlerrors.NewValidationf("$metadata fetch returned HTTP %d from %s", resp.StatusCode, metadataUrl)
1524+
}
1525+
1526+
body, err = io.ReadAll(resp.Body)
1527+
if err != nil {
1528+
return "", "", mdlerrors.NewBackend("read $metadata response", err)
1529+
}
1530+
}
15311531

15321532
// Hash calculation (same for both HTTP and local file)
15331533
metadata = string(body)

mdl/executor/cmd_security_write.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,35 @@ func execCreateModuleRole(ctx *ExecContext, s *ast.CreateModuleRoleStmt) error {
3131
return mdlerrors.NewBackend(fmt.Sprintf("read module security for %s", s.Name.Module), err)
3232
}
3333

34-
// Check if role already exists
34+
// Check if role already exists. Mendix treats role names case-insensitively
35+
// (CE0123), so match that way. An auto-provisioned role collides with any
36+
// user-requested casing; let AddModuleRole overwrite to adopt the caller's
37+
// casing so later case-sensitive lookups (GRANT ACCESS TO x.user) succeed.
3538
for _, mr := range ms.ModuleRoles {
36-
if mr.Name == s.Name.Name {
37-
if mr.Description == autoDocumentRoleDescription {
38-
if !ctx.Quiet {
39-
fmt.Fprintf(ctx.Output, "Module role %s.%s already exists (auto-provisioned)\n", s.Name.Module, s.Name.Name)
39+
if !strings.EqualFold(mr.Name, s.Name.Name) {
40+
continue
41+
}
42+
if mr.Description == autoDocumentRoleDescription {
43+
oldQualified := s.Name.Module + "." + mr.Name
44+
newQualified := s.Name.Module + "." + s.Name.Name
45+
if err := ctx.Backend.AddModuleRole(ms.ID, s.Name.Name, s.Description); err != nil {
46+
return mdlerrors.NewBackend("create module role", err)
47+
}
48+
// If the casing actually changed, propagate the rename across every
49+
// unit that referenced the old name (AllowedModuleRoles on microflows,
50+
// pages, published REST services, etc.). Without this, mx check fails
51+
// with CE1613 "selected module role X no longer exists".
52+
if oldQualified != newQualified {
53+
if _, err := ctx.Backend.UpdateQualifiedNameInAllUnits(oldQualified, newQualified); err != nil {
54+
return mdlerrors.NewBackend(fmt.Sprintf("rename references %s -> %s", oldQualified, newQualified), err)
4055
}
41-
return nil
4256
}
43-
return mdlerrors.NewAlreadyExists("module role", s.Name.Module+"."+s.Name.Name)
57+
if !ctx.Quiet {
58+
fmt.Fprintf(ctx.Output, "Module role %s.%s already exists (auto-provisioned)\n", s.Name.Module, s.Name.Name)
59+
}
60+
return nil
4461
}
62+
return mdlerrors.NewAlreadyExists("module role", s.Name.Module+"."+s.Name.Name)
4563
}
4664

4765
if err := ctx.Backend.AddModuleRole(ms.ID, s.Name.Name, s.Description); err != nil {

sdk/mpr/writer_security.go

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package mpr
44

55
import (
66
"fmt"
7+
"strings"
78

89
"github.com/mendixlabs/mxcli/model"
910

@@ -170,8 +171,53 @@ func (w *Writer) RemoveFromAllowedRoles(unitID model.ID, roleName string) (bool,
170171
// ============================================================================
171172

172173
// AddModuleRole adds a new module role to the module's Security$ModuleSecurity unit.
174+
// If a role with the same name (case-insensitive) already exists, the existing role's
175+
// Name is overwritten with the caller-supplied casing and Description is updated.
176+
// Mendix Studio Pro rejects case-insensitive duplicate role names with CE0123, so
177+
// merging into the existing entry matches runtime semantics — and preserves the
178+
// caller's casing for downstream case-sensitive lookups (e.g., GRANT ACCESS TO x.user).
173179
func (w *Writer) AddModuleRole(unitID model.ID, roleName, description string) error {
174180
return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) {
181+
// Get existing ModuleRoles array
182+
existing := getBsonArray(doc, "ModuleRoles")
183+
if existing == nil {
184+
existing = bson.A{int32(1)}
185+
}
186+
187+
// If a case-insensitive duplicate already exists, overwrite its Name and
188+
// Description with the caller's values. This keeps the ID stable (any
189+
// references to it remain valid) while adopting the newly-requested casing.
190+
for i, item := range existing {
191+
role, ok := item.(bson.D)
192+
if !ok {
193+
continue
194+
}
195+
matched := false
196+
for _, field := range role {
197+
if field.Key == "Name" {
198+
if name, ok := field.Value.(string); ok && strings.EqualFold(name, roleName) {
199+
matched = true
200+
}
201+
break
202+
}
203+
}
204+
if !matched {
205+
continue
206+
}
207+
for j, field := range role {
208+
switch field.Key {
209+
case "Name":
210+
role[j].Value = roleName
211+
case "Description":
212+
if description != "" {
213+
role[j].Value = description
214+
}
215+
}
216+
}
217+
existing[i] = role
218+
return setBsonField(doc, "ModuleRoles", existing), nil
219+
}
220+
175221
// Build the new role BSON document
176222
newRole := bson.D{
177223
{Key: "$Type", Value: "Security$ModuleRole"},
@@ -180,12 +226,6 @@ func (w *Writer) AddModuleRole(unitID model.ID, roleName, description string) er
180226
{Key: "Description", Value: description},
181227
}
182228

183-
// Get existing ModuleRoles array
184-
existing := getBsonArray(doc, "ModuleRoles")
185-
if existing == nil {
186-
existing = bson.A{int32(1)}
187-
}
188-
189229
existing = append(existing, newRole)
190230
return setBsonField(doc, "ModuleRoles", existing), nil
191231
})

0 commit comments

Comments
 (0)