Tests/Unit/ImportCIEMScript.Tests.ps1

BeforeAll {
    Remove-Module Devolutions.CIEM -Force -ErrorAction SilentlyContinue
    Import-Module (Join-Path $PSScriptRoot '..' '..' 'Devolutions.CIEM.psd1')

    $script:UniversalScriptsPath = Join-Path $PSScriptRoot '..' '..' '.universal' 'scripts.ps1'
    $script:UniversalScriptsExists = Test-Path -Path $script:UniversalScriptsPath -PathType Leaf
    $script:UniversalScriptsContent = if ($script:UniversalScriptsExists) { Get-Content -Path $script:UniversalScriptsPath -Raw } else { '' }
    $script:ImportScriptPath = Join-Path $PSScriptRoot '..' '..' 'Public' 'Import-CIEMScript.ps1'
    $script:ImportScriptContent = Get-Content -Path $script:ImportScriptPath -Raw
    $script:AppScriptPath = Join-Path $PSScriptRoot '..' '..' 'modules' 'Devolutions.CIEM.PSU' 'Public' 'New-DevolutionsCIEMApp.ps1'
    $script:AppScriptContent = Get-Content -Path $script:AppScriptPath -Raw
    $script:ManifestPath = Join-Path $PSScriptRoot '..' '..' 'data' 'psu-scripts.json'
    $script:ManifestContent = Get-Content -Path $script:ManifestPath -Raw
    $script:Manifest = $script:ManifestContent | ConvertFrom-Json -Depth 10
    $script:RemediationTemplateRoot = Join-Path $PSScriptRoot '..' '..' 'modules' 'Devolutions.CIEM.Graph' 'Data' 'attack_path_remediation_scripts'
    $script:RemediationScriptTemplatePath = Join-Path $PSScriptRoot '..' '..' 'modules' 'Devolutions.CIEM.Graph' 'Data' 'attack_path_remediation_script_template.ps1'
}

Describe 'Import-CIEMScript registration model' {
    Context 'Command exposure' {
        It 'Exposes Import-CIEMScript as a public module command' {
            Get-Command Import-CIEMScript -Module Devolutions.CIEM -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }
    }

    Context 'Manifest' {
        It 'Defines the Checks and Identities script folders' {
            @($script:Manifest.folders) | Should -Contain 'Checks'
            @($script:Manifest.folders) | Should -Contain 'Identities'
            @($script:Manifest.folders) | Should -Contain 'Identities/AttackPaths'
            @($script:Manifest.folders) | Should -Not -Contain 'Identity'
        }

        It 'Defines core Checks scripts in psu-scripts.json' {
            $script:ManifestContent | Should -Match '"name"\s*:\s*"Checks/New-CIEMScanRun"'
            $script:ManifestContent | Should -Match '"name"\s*:\s*"Checks/Start-CIEMAzureDiscovery"'
        }

        It 'Keeps core CIEM automation script names under Checks' {
            foreach ($scriptName in @($script:Manifest.scripts | ForEach-Object { $_.name })) {
                $scriptName | Should -Match '^Checks/'
            }
        }

        It 'Does not define legacy Devolutions.CIEM or Users rooted names' {
            $script:ManifestContent | Should -Not -Match '"name"\s*:\s*"Devolutions\.CIEM'
            $script:ManifestContent | Should -Not -Match '"name"\s*:\s*"Users/'
        }

        It 'Does not define a legacy Devolutions.CIEM folder' {
            @($script:Manifest.folders) | Should -Not -Contain 'Devolutions.CIEM'
        }

        It 'Defines attack path script settings with basename PSU script names in psu-scripts.json' {
            $script:ManifestContent | Should -Match '"path"\s*:\s*"modules/Devolutions.CIEM.Graph/Data/attack_path_remediation_scripts"'
            $script:ManifestContent | Should -Match '"templatePath"\s*:\s*"modules/Devolutions.CIEM.Graph/Data/attack_path_remediation_script_template.ps1"'
            $script:ManifestContent | Should -Match '"namePrefix"\s*:\s*""'
            $script:ManifestContent | Should -Not -Match '"namePrefix"\s*:\s*"Checks/AttackPathRemediation-"'
        }
    }

    Context 'PSU app startup registration' {
        It 'Does not ship a .universal scripts.ps1 module resource that creates a read-only Devolutions.CIEM Scripts folder' {
            $script:UniversalScriptsPath | Should -Not -Exist
        }

        It 'Registers PSU automation scripts from the app startup path' {
            $script:AppScriptContent | Should -Match 'Import-CIEMScript'
        }
    }

    Context 'Import-CIEMScript implementation' {
        It 'Loads registration from the shared psu-scripts.json manifest' {
            $script:ImportScriptContent | Should -Match 'psu-scripts\.json'
        }

        It 'Uses generic registration loops rather than hardcoded script names' {
            $script:ImportScriptContent | Should -Not -Match '-Name\s+["'']Checks/New-CIEMScanRun["'']'
            $script:ImportScriptContent | Should -Not -Match '-Name\s+["'']Checks/Start-CIEMAzureDiscovery["'']'
            $script:ImportScriptContent | Should -Not -Match 'modules/Devolutions.CIEM.Graph/Data/attack_path_remediation_scripts/disabled-account'
        }

        It 'Registers core scripts from validated file content as script blocks' {
            $script:ImportScriptContent | Should -Match 'ScriptBlock\s+=\s+\[scriptblock\]::Create\(\$content\)'
            $script:ImportScriptContent | Should -Not -Match '(?m)^\s*-Path \$path\s*$'
        }

        It 'Prunes stale CIEM scripts before registering core scripts' {
            $script:ImportScriptContent | Should -Match 'Get-PSUScript'
            $script:ImportScriptContent | Should -Match 'Remove-PSUScript'
        }

        It 'Syncs manifest script folders without creating a legacy Devolutions.CIEM folder' {
            $script:ImportScriptContent | Should -Match 'Get-PSUFolder'
            $script:ImportScriptContent | Should -Match 'New-PSUFolder'
            $script:ImportScriptContent | Should -Match 'manifest\.folders'
            $script:ImportScriptContent | Should -Not -Match 'New-PSUFolder[^\r\n]*Devolutions\.CIEM'
        }

        It 'Uses managed notes and upsert behavior for CIEM-owned scripts' {
            $script:ImportScriptContent | Should -Match 'ManagedBy=Devolutions\.CIEM;Source=data/psu-scripts\.json'
            $script:ImportScriptContent | Should -Match 'Set-PSUScript'
        }

        It 'Rejects rooted or parent-traversal script paths in the manifest' {
            $script:ImportScriptContent | Should -Match 'IsPathRooted'
            $script:ImportScriptContent | Should -Match 'parent path traversal'
        }

        It 'Registers attack path scripts from remediation templates in PSU Automation' {
            $script:ImportScriptContent | Should -Match 'remediationTemplates'
            $script:ImportScriptContent | Should -Match 'templateFiles'
            $script:ImportScriptContent | Should -Match 'MergeCIEMAttackPathRemediationScriptTemplate'
        }
    }

    Context 'Import-CIEMScript behavior' {
        It 'Prunes stale CIEM scripts and keeps unrelated scripts intact' {
            Mock -ModuleName Devolutions.CIEM Get-Command {
                [pscustomobject]@{
                    Name = $Name
                }
            } -ParameterFilter { $Name -in @('New-PSUScript', 'Get-PSUScript', 'Remove-PSUScript', 'Set-PSUScript', 'Get-PSUFolder', 'New-PSUFolder') }

            Mock -ModuleName Devolutions.CIEM New-PSUScript {}
            Mock -ModuleName Devolutions.CIEM Remove-PSUScript {}
            Mock -ModuleName Devolutions.CIEM Set-PSUScript {}
            Mock -ModuleName Devolutions.CIEM New-PSUFolder {}
            Mock -ModuleName Devolutions.CIEM Get-PSUFolder {
                return @(
                    [pscustomobject]@{ Name = 'Checks'; Path = 'Checks'; Type = 'Script' }
                    [pscustomobject]@{ Name = 'Devolutions.CIEM'; Path = 'Devolutions.CIEM'; Type = 'Script' }
                )
            }
            Mock -ModuleName Devolutions.CIEM Get-PSUScript {
                param($Name)
                if ($PSBoundParameters.ContainsKey('Name')) {
                    return @()
                }

                return @(
                    [pscustomobject]@{ Name = 'Checks/New-CIEMScanRun' }
                    [pscustomobject]@{ Name = 'Devolutions.CIEM/Start-CIEMAzureDiscovery' }
                    [pscustomobject]@{ Name = 'Checks/AttackPathRemediation-guest-user-holding-a-privileged-role' }
                    [pscustomobject]@{ Name = 'Identities/AttackPaths/AttackPathRemediation-disabled-account-still-holding-active-role-assignments' }
                    [pscustomobject]@{ Name = 'management-port-open-to-the-internet'; FullPath = 'management-port-open-to-the-internet.ps1'; CommitNotes = 'ManagedBy=Devolutions.CIEM;Source=data/psu-scripts.json' }
                    [pscustomobject]@{ Name = 'Users/adam/Dropbox/GitRepos/Devolutions-CIEM/psu-app/Checks/New-CIEMScanRun.ps1' }
                    [pscustomobject]@{ Name = 'Infra/RotateCertificates' }
                )
            }

            $result = Import-CIEMScript

            $result.Status | Should -Be 'Registered'
            $result.CoreScripts | Should -Be @($script:Manifest.scripts).Count
            $result.AttackPathScripts | Should -Be @(Get-ChildItem -Path $script:RemediationTemplateRoot -Filter '*.ps1' -File).Count
            $result.PrunedScripts | Should -Be 5
            $result.SyncedFolders | Should -Be 2

            Should -Invoke -CommandName Remove-PSUScript -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter { $Script.Name -eq 'Devolutions.CIEM/Start-CIEMAzureDiscovery' }
            Should -Invoke -CommandName Remove-PSUScript -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter { $Script.Name -eq 'Checks/AttackPathRemediation-guest-user-holding-a-privileged-role' }
            Should -Invoke -CommandName Remove-PSUScript -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter { $Script.Name -eq 'Users/adam/Dropbox/GitRepos/Devolutions-CIEM/psu-app/Checks/New-CIEMScanRun.ps1' }
            Should -Invoke -CommandName Remove-PSUScript -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter { $Script.Name -eq 'Identities/AttackPaths/AttackPathRemediation-disabled-account-still-holding-active-role-assignments' }
            Should -Invoke -CommandName Remove-PSUScript -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter { $Script.Name -eq 'management-port-open-to-the-internet' }
            Should -Invoke -CommandName Remove-PSUScript -ModuleName Devolutions.CIEM -Times 0 -ParameterFilter { $Script.Name -eq 'Infra/RotateCertificates' }
            Should -Invoke -CommandName New-PSUFolder -ModuleName Devolutions.CIEM -Times 0 -ParameterFilter { $Path -eq 'Checks' }
            Should -Invoke -CommandName New-PSUFolder -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter { $Path -eq 'Identities' -and [string]$Type -eq 'Script' }
            Should -Invoke -CommandName New-PSUFolder -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter { $Path -eq 'Identities/AttackPaths' -and [string]$Type -eq 'Script' }
            Should -Invoke -CommandName New-PSUFolder -ModuleName Devolutions.CIEM -Times 0 -ParameterFilter { $Path -eq 'Devolutions.CIEM' }
            Should -Invoke -CommandName New-PSUScript -ModuleName Devolutions.CIEM -Times (@($script:Manifest.scripts).Count + @(Get-ChildItem -Path $script:RemediationTemplateRoot -Filter '*.ps1' -File).Count)
            Should -Invoke -CommandName New-PSUScript -ModuleName Devolutions.CIEM -Times @(Get-ChildItem -Path $script:RemediationTemplateRoot -Filter '*.ps1' -File).Count -ParameterFilter {
                $Name -notmatch '/' -and $Path -match '^Identities/AttackPaths/[^/]+\.ps1$'
            }
            Should -Invoke -CommandName New-PSUScript -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter {
                $Name -eq 'disabled-account-still-holding-active-role-assignments' -and
                $Path -eq 'Identities/AttackPaths/disabled-account-still-holding-active-role-assignments.ps1'
            }
            Should -Invoke -CommandName Set-PSUScript -ModuleName Devolutions.CIEM -Times 0
        }

        It 'Creates scripts when Get-PSUScript returns a null named lookup result' {
            Mock -ModuleName Devolutions.CIEM Get-Command {
                [pscustomobject]@{
                    Name = $Name
                }
            } -ParameterFilter { $Name -in @('New-PSUScript', 'Get-PSUScript', 'Remove-PSUScript', 'Set-PSUScript', 'Get-PSUFolder', 'New-PSUFolder') }

            Mock -ModuleName Devolutions.CIEM New-PSUScript {}
            Mock -ModuleName Devolutions.CIEM Remove-PSUScript {}
            Mock -ModuleName Devolutions.CIEM Set-PSUScript {}
            Mock -ModuleName Devolutions.CIEM New-PSUFolder {}
            Mock -ModuleName Devolutions.CIEM Get-PSUFolder { return @() }
            Mock -ModuleName Devolutions.CIEM Get-PSUScript {
                param($Name)
                if ($PSBoundParameters.ContainsKey('Name')) {
                    return $null
                }

                return @()
            }

            $result = Import-CIEMScript

            $result.Status | Should -Be 'Registered'
            $result.TotalScripts | Should -Be (@($script:Manifest.scripts).Count + @(Get-ChildItem -Path $script:RemediationTemplateRoot -Filter '*.ps1' -File).Count)
            Should -Invoke -CommandName New-PSUScript -ModuleName Devolutions.CIEM -Times $result.TotalScripts
            Should -Invoke -CommandName Set-PSUScript -ModuleName Devolutions.CIEM -Times 0
        }

        It 'Creates attack path PSU scripts by combining the shared template with each attack path body' {
            Mock -ModuleName Devolutions.CIEM Get-Command {
                [pscustomobject]@{
                    Name = $Name
                }
            } -ParameterFilter { $Name -in @('New-PSUScript', 'Get-PSUScript', 'Remove-PSUScript', 'Set-PSUScript', 'Get-PSUFolder', 'New-PSUFolder') }

            Mock -ModuleName Devolutions.CIEM New-PSUScript {}
            Mock -ModuleName Devolutions.CIEM Remove-PSUScript {}
            Mock -ModuleName Devolutions.CIEM Set-PSUScript {}
            Mock -ModuleName Devolutions.CIEM New-PSUFolder {}
            Mock -ModuleName Devolutions.CIEM Get-PSUFolder { return @() }
            Mock -ModuleName Devolutions.CIEM Get-PSUScript {
                param($Name)
                if ($PSBoundParameters.ContainsKey('Name')) {
                    return $null
                }

                return @()
            }

            $result = Import-CIEMScript

            $result.Status | Should -Be 'Registered'
            Should -Invoke -CommandName New-PSUScript -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter {
                $Name -eq 'management-port-open-to-the-internet' -and
                $Path -eq 'Identities/AttackPaths/management-port-open-to-the-internet.ps1' -and
                $ScriptBlock.ToString() -match 'function Assert-CIEMAttackPathRemediationScriptResolved' -and
                $ScriptBlock.ToString() -match 'Assert-CIEMAttackPathRemediationScriptResolved -ScriptBlock \$MyInvocation\.MyCommand\.ScriptBlock' -and
                $ScriptBlock.ToString() -match '\{\{NSG_RULE_DELETE_COMMANDS\}\}' -and
                $ScriptBlock.ToString() -notmatch '\{\{CIEM_ATTACK_PATH_SCRIPT_BODY\}\}'
            }
        }

        It 'Queries folders by leaf name to avoid recursive folder enumeration' {
            Mock -ModuleName Devolutions.CIEM Get-Command {
                [pscustomobject]@{
                    Name = $Name
                }
            } -ParameterFilter { $Name -in @('New-PSUScript', 'Get-PSUScript', 'Remove-PSUScript', 'Set-PSUScript', 'Get-PSUFolder', 'New-PSUFolder') }

            Mock -ModuleName Devolutions.CIEM New-PSUScript {}
            Mock -ModuleName Devolutions.CIEM Remove-PSUScript {}
            Mock -ModuleName Devolutions.CIEM Set-PSUScript {}
            Mock -ModuleName Devolutions.CIEM New-PSUFolder {}
            Mock -ModuleName Devolutions.CIEM Get-PSUFolder {
                param($Name)
                if (-not $PSBoundParameters.ContainsKey('Name')) {
                    throw 'Recursive folder enumeration is not supported.'
                }

                switch ($Name) {
                    'Checks' { return [pscustomobject]@{ Name = 'Checks'; Path = 'Checks'; Type = 'Script' } }
                    'Identities' { return [pscustomobject]@{ Name = 'Identities'; Path = 'Identities'; Type = 'Script' } }
                    'AttackPaths' { return [pscustomobject]@{ Name = 'AttackPaths'; Path = 'Identities/AttackPaths'; Type = 'Script' } }
                    default { return @() }
                }
            }
            Mock -ModuleName Devolutions.CIEM Get-PSUScript {
                param($Name)
                if ($PSBoundParameters.ContainsKey('Name')) {
                    return $null
                }

                return @()
            }

            $result = Import-CIEMScript

            $result.Status | Should -Be 'Registered'
            $result.SyncedFolders | Should -Be 0
            Should -Invoke -CommandName Get-PSUFolder -ModuleName Devolutions.CIEM -Times 3
            Should -Invoke -CommandName Get-PSUFolder -ModuleName Devolutions.CIEM -Times 0 -ParameterFilter { [string]::IsNullOrEmpty($Name) }
            Should -Invoke -CommandName New-PSUFolder -ModuleName Devolutions.CIEM -Times 0
        }

    }

    Context 'Remediation template behavior' {
        It 'Provides a shared runtime wrapper for attack path script bodies' {
            $script:RemediationScriptTemplatePath | Should -Exist
            $content = Get-Content -Path $script:RemediationScriptTemplatePath -Raw
            $content | Should -Match '# Attack path: \{\{PATTERN_NAME\}\}'
            $content | Should -Match '\{\{CIEM_ATTACK_PATH_SCRIPT_BODY\}\}'
            $content | Should -Match 'function Assert-CIEMAttackPathRemediationScriptResolved'
            $content | Should -Match 'Assert-CIEMAttackPathRemediationScriptResolved -ScriptBlock \$MyInvocation\.MyCommand\.ScriptBlock'
            $content | Should -Not -Match '(?m)^\$__ciemTemplateContent\s*='
            $content | Should -Not -Match '(?m)^\$__ciemUnresolvedTokens\s*='
            $content | Should -Match 'Write-Output'
            $content | Should -Not -Match 'Write-Host'
        }

        It 'Fails before executing the script body when shared placeholders are unresolved' {
            $content = Get-Content -Path $script:RemediationScriptTemplatePath -Raw
            $content = $content.Replace('{{CIEM_ATTACK_PATH_SCRIPT_BODY}}', "throw 'script body executed'")

            { & ([scriptblock]::Create($content)) } | Should -Throw '*unresolved tokens*'
        }

        It 'Stores existing attack path scripts as command bodies consumed by the shared template' {
            $templates = @(Get-ChildItem -Path $script:RemediationTemplateRoot -Filter '*.ps1' -File)
            foreach ($template in $templates) {
                $content = Get-Content -Path $template.FullName -Raw
                $content | Should -Match '\{\{(ROLE_ASSIGNMENT_DELETE_COMMANDS|NSG_RULE_DELETE_COMMANDS|GROUP_MEMBER_REMOVE_COMMANDS)\}\}'
                $content | Should -Not -Match '# Attack path: \{\{PATTERN_NAME\}\}'
                $content | Should -Not -Match '\$__ciemTemplateProbe'
                $content | Should -Not -Match 'az account show'
                $content | Should -Not -Match 'Write-Host'
            }
        }
    }
}