@@ -12,6 +12,7 @@ import (
1212 "github.com/mendixlabs/mxcli/sdk/domainmodel"
1313 "github.com/mendixlabs/mxcli/sdk/microflows"
1414 "github.com/mendixlabs/mxcli/sdk/pages"
15+ "github.com/mendixlabs/mxcli/sdk/security"
1516)
1617
1718func TestExecCreateModule_Mock (t * testing.T ) {
@@ -435,3 +436,175 @@ func TestExecDropAssociation_Mock_NotFound(t *testing.T) {
435436 Name : ast.QualifiedName {Module : "MyModule" , Name : "NonExistent" },
436437 }))
437438}
439+
440+ // TestDropThenCreatePreservesMicroflowUnitID is a regression test for the
441+ // MPR corruption bug documented in docs/MXCLI_MPR_CORRUPTION_PROMPT_0015.md.
442+ //
443+ // When a script runs `DROP MICROFLOW X; CREATE OR MODIFY MICROFLOW X ...` in
444+ // the same session, the executor used to delete the Unit row and then insert
445+ // a new one with a freshly generated UUID. Studio Pro treats the rewritten
446+ // ContainerID/UnitID pair as an unrelated document and refuses to open the
447+ // resulting .mpr ("file does not look like a Mendix Studio Pro project").
448+ //
449+ // The fix records the UnitID of dropped microflows on the executor cache and
450+ // reuses it when a subsequent CREATE OR REPLACE/MODIFY targets the same
451+ // qualified name, so the delete+insert behaves like an in-place update.
452+ func TestDropThenCreatePreservesMicroflowUnitID (t * testing.T ) {
453+ mod := mkModule ("MyModule" )
454+ mf := mkMicroflow (mod .ID , "DoSomething" )
455+ originalID := mf .ID
456+ mf .AllowedModuleRoles = []model.ID {"MyModule.Admin" , "MyModule.User" }
457+
458+ h := mkHierarchy (mod )
459+ withContainer (h , mf .ContainerID , mod .ID )
460+
461+ listedMicroflows := []* microflows.Microflow {mf }
462+ var createdID model.ID
463+ var createdRoles []model.ID
464+
465+ mb := & mock.MockBackend {
466+ IsConnectedFunc : func () bool { return true },
467+ ListModulesFunc : func () ([]* model.Module , error ) {
468+ return []* model.Module {mod }, nil
469+ },
470+ ListMicroflowsFunc : func () ([]* microflows.Microflow , error ) {
471+ return listedMicroflows , nil
472+ },
473+ DeleteMicroflowFunc : func (id model.ID ) error {
474+ // Simulate real deletion: hide the microflow from subsequent
475+ // ListMicroflows calls so CREATE OR MODIFY sees no existing unit
476+ // (matching the bug reproduction exactly).
477+ listedMicroflows = nil
478+ return nil
479+ },
480+ CreateMicroflowFunc : func (m * microflows.Microflow ) error {
481+ createdID = m .ID
482+ createdRoles = cloneRoleIDs (m .AllowedModuleRoles )
483+ return nil
484+ },
485+ GetModuleSecurityFunc : func (moduleID model.ID ) (* security.ModuleSecurity , error ) {
486+ return & security.ModuleSecurity {
487+ BaseElement : model.BaseElement {ID : nextID ("ms" )},
488+ ContainerID : moduleID ,
489+ }, nil
490+ },
491+ AddModuleRoleFunc : func (moduleSecurityID model.ID , name , description string ) error {
492+ return nil
493+ },
494+ ListDomainModelsFunc : func () ([]* domainmodel.DomainModel , error ) {
495+ return nil , nil
496+ },
497+ ListConsumedRestServicesFunc : func () ([]* model.ConsumedRestService , error ) {
498+ return nil , nil
499+ },
500+ }
501+
502+ // Need an Executor so ctx.executor is set (trackCreatedMicroflow uses it).
503+ exec := New (& bytesWriter {})
504+ ctx := & ExecContext {
505+ Context : t .Context (),
506+ Backend : mb ,
507+ Output : exec .output ,
508+ Format : FormatTable ,
509+ executor : exec ,
510+ }
511+ exec .backend = mb
512+ withHierarchy (h )(ctx )
513+
514+ if err := execDropMicroflow (ctx , & ast.DropMicroflowStmt {
515+ Name : ast.QualifiedName {Module : "MyModule" , Name : "DoSomething" },
516+ }); err != nil {
517+ t .Fatalf ("DROP MICROFLOW failed: %v" , err )
518+ }
519+
520+ // The UnitID and ContainerID must have been stashed on the cache before deletion.
521+ if ctx .Cache == nil || ctx .Cache .droppedMicroflows == nil ||
522+ ctx .Cache .droppedMicroflows ["MyModule.DoSomething" ] == nil ||
523+ ctx .Cache .droppedMicroflows ["MyModule.DoSomething" ].ID != originalID {
524+ t .Fatalf ("expected droppedMicroflows[MyModule.DoSomething].ID = %q, got cache=%+v" ,
525+ originalID , ctx .Cache )
526+ }
527+
528+ // CREATE OR MODIFY with the same qualified name must reuse the dropped ID.
529+ createStmt := & ast.CreateMicroflowStmt {
530+ Name : ast.QualifiedName {Module : "MyModule" , Name : "DoSomething" },
531+ CreateOrModify : true ,
532+ Body : nil , // empty body is fine for this test
533+ }
534+ if err := execCreateMicroflow (ctx , createStmt ); err != nil {
535+ t .Fatalf ("CREATE OR MODIFY MICROFLOW failed: %v" , err )
536+ }
537+
538+ if createdID != originalID {
539+ t .Fatalf ("CREATE OR MODIFY must reuse dropped UnitID: got %q, want %q" ,
540+ createdID , originalID )
541+ }
542+ if len (createdRoles ) != 2 || createdRoles [0 ] != "MyModule.Admin" || createdRoles [1 ] != "MyModule.User" {
543+ t .Fatalf ("CREATE OR MODIFY must preserve dropped allowed roles: got %v" , createdRoles )
544+ }
545+
546+ // The cache entry must be consumed so repeated CREATEs don't collide.
547+ if ctx .Cache != nil && ctx .Cache .droppedMicroflows != nil {
548+ if _ , stillThere := ctx .Cache .droppedMicroflows ["MyModule.DoSomething" ]; stillThere {
549+ t .Errorf ("droppedMicroflows entry should be cleared after reuse" )
550+ }
551+ }
552+ }
553+
554+ func TestCreateOrModifyMicroflowPreservesAllowedRoles (t * testing.T ) {
555+ mod := mkModule ("MyModule" )
556+ mf := mkMicroflow (mod .ID , "DoSomething" )
557+ mf .AllowedModuleRoles = []model.ID {"MyModule.Admin" }
558+
559+ h := mkHierarchy (mod )
560+ withContainer (h , mf .ContainerID , mod .ID )
561+
562+ var updatedRoles []model.ID
563+ mb := & mock.MockBackend {
564+ IsConnectedFunc : func () bool { return true },
565+ ListModulesFunc : func () ([]* model.Module , error ) {
566+ return []* model.Module {mod }, nil
567+ },
568+ ListMicroflowsFunc : func () ([]* microflows.Microflow , error ) {
569+ return []* microflows.Microflow {mf }, nil
570+ },
571+ UpdateMicroflowFunc : func (updated * microflows.Microflow ) error {
572+ updatedRoles = cloneRoleIDs (updated .AllowedModuleRoles )
573+ return nil
574+ },
575+ ListDomainModelsFunc : func () ([]* domainmodel.DomainModel , error ) {
576+ return nil , nil
577+ },
578+ ListConsumedRestServicesFunc : func () ([]* model.ConsumedRestService , error ) {
579+ return nil , nil
580+ },
581+ }
582+
583+ exec := New (& bytesWriter {})
584+ ctx := & ExecContext {
585+ Context : t .Context (),
586+ Backend : mb ,
587+ Output : exec .output ,
588+ Format : FormatTable ,
589+ executor : exec ,
590+ }
591+ exec .backend = mb
592+ withHierarchy (h )(ctx )
593+
594+ if err := execCreateMicroflow (ctx , & ast.CreateMicroflowStmt {
595+ Name : ast.QualifiedName {Module : "MyModule" , Name : "DoSomething" },
596+ CreateOrModify : true ,
597+ }); err != nil {
598+ t .Fatalf ("CREATE OR MODIFY MICROFLOW failed: %v" , err )
599+ }
600+
601+ if len (updatedRoles ) != 1 || updatedRoles [0 ] != "MyModule.Admin" {
602+ t .Fatalf ("expected existing allowed roles to be preserved, got %v" , updatedRoles )
603+ }
604+ }
605+
606+ // bytesWriter is a trivial io.Writer used to satisfy New() in the regression
607+ // test above. We don't care about captured output for this test.
608+ type bytesWriter struct {}
609+
610+ func (* bytesWriter ) Write (p []byte ) (int , error ) { return len (p ), nil }
0 commit comments