Public/Tests/New-APFConfigDeployment.Tests.ps1

BeforeAll {
    $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config'
    $env:TCS_SKIP_UPDATE_CHECK = '1'
    $env:TCS_TELEMETRY_OPTOUT = '1'
    $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
    Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.intune.packaging.psd1') -Force

    # Returns the string assigned to a variable in a script, read from the parsed script (not by running it)
    function Get-AssignedString {
        param([string]$Path, [string]$Variable)
        $tokens = $null
        $errors = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
        $errors | Should -BeNullOrEmpty
        $assignment = $ast.Find({
                param($node)
                $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and $node.Left.VariablePath.UserPath -eq $Variable
            }, $true)
        $assignment.Right.Expression | Should -BeOfType ([System.Management.Automation.Language.StringConstantExpressionAst])
        $assignment.Right.Expression.Value
    }
}

AfterAll {
    Remove-Module -Name tcs.intune.packaging -Force -ErrorAction SilentlyContinue
}
Describe 'New-APFConfigDeployment' {
    BeforeEach {
        $Destination = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString())
        $null = New-Item -Path $Destination -ItemType Directory
    }

    Context 'Registry' {
        It 'Creates the registry package and adds the supplied registry value to the CSV file' {
            $null = New-APFConfigDeployment -ConfigurationType Registry -Name 'RegPkg' -Version '1.0.0.0' -DestinationFolder $Destination -RegistryValue 'HKLM:\Software,MySoftware,MyValue,String,MyData,ADD' -Confirm:$false
            $folder = Join-Path -Path $Destination -ChildPath 'RegPkg'
            $rows = @(Import-Csv -Path (Join-Path -Path $folder -ChildPath 'RegPkg_Registry.csv'))
            $rows.Count | Should -Be 1
            $rows[0].RegistryPath | Should -Be 'HKLM:\Software'
            $rows[0].ValueName | Should -Be 'MyValue'
            $rows[0].ValueType | Should -Be 'String'
            $rows[0].State | Should -Be 'ADD'
            $config = Get-Content -Path (Join-Path -Path $folder -ChildPath 'config.installer.json') -Raw | ConvertFrom-Json
            $config.name | Should -Be 'RegPkg'
            $config.target | Should -Be 'System'
            $config.registryfile | Should -Be 'RegPkg_Registry.csv'
            Join-Path -Path $folder -ChildPath 'Intune-I-MainInstaller.ps1' | Should -Exist
            Join-Path -Path $folder -ChildPath 'README.md' | Should -Not -Exist
            (Get-Content -Path (Join-Path -Path $folder -ChildPath 'Intune-D-RegistryDetection.ps1') -Raw) | Should -Match "\`$AppName = 'RegPkg'"
        }

        It 'Rejects a registry value with the wrong number of fields' {
            $null = New-APFConfigDeployment -ConfigurationType Registry -Name 'BadReg' -Version '1.0' -DestinationFolder $Destination -RegistryValue 'HKLM:\Software,Only,Three' -ErrorVariable regError -ErrorAction SilentlyContinue -Confirm:$false
            "$regError" | Should -Match 'comma-separated values'
        }
    }

    Context 'Files' {
        It 'Copies the files and records their names' {
            $file = Join-Path -Path $TestDrive -ChildPath 'settings.json'
            Set-Content -Path $file -Value '{}'
            $null = New-APFConfigDeployment -ConfigurationType Files -Name 'FilePkg' -Version '2.0' -DestinationFolder $Destination -Files $file -FilesDirectoryName 'MyFiles' -Confirm:$false
            $folder = Join-Path -Path $Destination -ChildPath 'FilePkg'
            Join-Path -Path $folder -ChildPath 'settings.json' | Should -Exist
            $config = Get-Content -Path (Join-Path -Path $folder -ChildPath 'config.installer.json') -Raw | ConvertFrom-Json
            $config.files | Should -Be 'settings.json'
            $config.directory | Should -Be 'MyFiles'
            $config.version | Should -Be '2.0'
        }
    }

    Context 'StandAlone-Exe' {
        It 'Writes the executable name to the filename setting read by the installer and keeps CLIApp false' {
            $exe = Join-Path -Path $TestDrive -ChildPath 'tool.exe'
            Set-Content -Path $exe -Value 'x'
            $null = New-APFConfigDeployment -ConfigurationType StandAlone-Exe -Name 'ToolPkg' -Version '1.0' -Path $exe -CLIApp $false -DestinationFolder $Destination -Confirm:$false
            $folder = Join-Path -Path $Destination -ChildPath 'ToolPkg'
            Join-Path -Path $folder -ChildPath 'tool.exe' | Should -Exist
            $config = Get-Content -Path (Join-Path -Path $folder -ChildPath 'config.installer.json') -Raw | ConvertFrom-Json
            $config.filename | Should -Be 'tool.exe'
            $config.cli | Should -BeFalse
        }

        It 'Sets cli to true for command-line tools' {
            $exe = Join-Path -Path $TestDrive -ChildPath 'cli.exe'
            Set-Content -Path $exe -Value 'x'
            $null = New-APFConfigDeployment -ConfigurationType StandAlone-Exe -Name 'CliPkg' -Version '1.0' -Path $exe -CLIApp $true -DestinationFolder $Destination -Confirm:$false
            (Get-Content -Path (Join-Path -Path $Destination -ChildPath 'CliPkg/config.installer.json') -Raw | ConvertFrom-Json).cli | Should -BeTrue
        }
    }

    Context 'WindowsFeature and Script-OS' {
        It 'Creates the <Type> package with the configuration file name the installer reads' -ForEach @(
            @{ Type = 'WindowsFeature'; Detection = 'Intune-D-WindowsFeatureDetection.ps1' }
            @{ Type = 'Script-OS'; Detection = 'Intune-D-Detection.ps1' }
        ) {
            $null = New-APFConfigDeployment -ConfigurationType $Type -Name 'CfgPkg' -Version '1.0' -DestinationFolder $Destination -Confirm:$false
            $folder = Join-Path -Path $Destination -ChildPath 'CfgPkg'
            Join-Path -Path $folder -ChildPath 'CfgPkg_config.csv' | Should -Exist
            Join-Path -Path $folder -ChildPath $Detection | Should -Exist
            $config = Get-Content -Path (Join-Path -Path $folder -ChildPath 'config.installer.json') -Raw | ConvertFrom-Json
            $config.configfile | Should -Be 'CfgPkg_config.csv'
            $config.name | Should -Be 'CfgPkg'
        }
    }

    Context 'Standalone-Application' {
        It 'Copies the application folder and fills in the launcher details' {
            $app = Join-Path -Path $TestDrive -ChildPath 'PortableApp'
            $null = New-Item -Path (Join-Path -Path $app -ChildPath 'bin') -ItemType Directory -Force
            Set-Content -Path (Join-Path -Path $app -ChildPath 'bin/app.exe') -Value 'x'
            $null = New-APFConfigDeployment -ConfigurationType Standalone-Application -Name 'AppPkg' -Version '1.0' -Path $app -LauncherName 'app.exe' -LauncherRelativePath 'bin' -DestinationFolder $Destination -Confirm:$false
            $folder = Join-Path -Path $Destination -ChildPath 'AppPkg'
            Join-Path -Path $folder -ChildPath 'PortableApp/bin/app.exe' | Should -Exist
            $config = Get-Content -Path (Join-Path -Path $folder -ChildPath 'config.installer.json') -Raw | ConvertFrom-Json
            $config.filename | Should -Be 'PortableApp'
            $config.launchername | Should -Be 'app.exe'
        }
    }

    Context 'Script packages' {
        BeforeEach {
            $DeployScript = Join-Path -Path $TestDrive -ChildPath 'deploy.ps1'
            Set-Content -Path $DeployScript -Value 'exit 0'
        }

        It 'Creates a <Type> package that runs the supplied script as <Target>' -ForEach @(
            @{ Type = 'Script-App'; Target = 'system' }
            @{ Type = 'Script-User'; Target = 'user' }
        ) {
            $extra = Join-Path -Path $TestDrive -ChildPath 'settings.xml'
            Set-Content -Path $extra -Value '<x/>'
            $null = New-APFConfigDeployment -ConfigurationType $Type -Name 'ScriptPkg' -Version '1.0' -Path $DeployScript -IncludedFiles $extra -DestinationFolder $Destination -Confirm:$false
            $folder = Join-Path -Path $Destination -ChildPath 'ScriptPkg'
            foreach ($file in 'deploy.ps1', 'settings.xml', 'Intune-I-MainInstaller.ps1', 'Intune-D-Detection.ps1', 'Intune-Pre-Install.ps1', 'Intune-Post-Install.ps1', 'Write-DeploymentLog.ps1') {
                Join-Path -Path $folder -ChildPath $file | Should -Exist
            }
            Join-Path -Path $folder -ChildPath 'Intune-Custom.ps1' | Should -Not -Exist
            Join-Path -Path $folder -ChildPath 'README.md' | Should -Not -Exist
            $config = Get-Content -Path (Join-Path -Path $folder -ChildPath 'config.installer.json') -Raw | ConvertFrom-Json
            $config.scriptfile | Should -Be 'deploy.ps1'
            $config.target | Should -Be $Target
            $config.includedfiles | Should -Be 'settings.xml'
            $config.version | Should -Be '1.0'
            (Get-Content -Path (Join-Path -Path $folder -ChildPath 'Intune-D-Detection.ps1') -Raw) | Should -Match "\`$AppName = 'ScriptPkg'"
        }

        It 'Rejects a deployment script that is not a .ps1 file' {
            $cmd = Join-Path -Path $TestDrive -ChildPath 'deploy.cmd'
            Set-Content -Path $cmd -Value 'x'
            $null = New-APFConfigDeployment -ConfigurationType Script-App -Name 'Bad' -Version '1.0' -Path $cmd -DestinationFolder $Destination -ErrorVariable apfError -ErrorAction SilentlyContinue -Confirm:$false
            "$apfError" | Should -Match '\.ps1'
        }

        It 'Creates a Custom package with the placeholder script and the chosen target' {
            $null = New-APFConfigDeployment -ConfigurationType Custom -Name 'CustomPkg' -Version '2.0' -Target User -DestinationFolder $Destination -Confirm:$false
            $folder = Join-Path -Path $Destination -ChildPath 'CustomPkg'
            Join-Path -Path $folder -ChildPath 'Intune-Custom.ps1' | Should -Exist
            $config = Get-Content -Path (Join-Path -Path $folder -ChildPath 'config.installer.json') -Raw | ConvertFrom-Json
            $config.scriptfile | Should -Be 'Intune-Custom.ps1'
            $config.target | Should -Be 'user'
        }
    }

    Context 'Common behaviour' {
        It 'Creates nothing with -WhatIf' {
            $null = New-APFConfigDeployment -ConfigurationType Registry -Name 'WhatIfPkg' -Version '1.0' -DestinationFolder $Destination -WhatIf
            Join-Path -Path $Destination -ChildPath 'WhatIfPkg' | Should -Not -Exist
        }

        It 'Passes the package folder and main installer to the intunewin helper' {
            Mock -ModuleName tcs.intune.packaging New-APFIntuneWinPackage { [System.IO.FileInfo](Join-Path -Path $OutputFolder -ChildPath "$PackageName.intunewin") }
            $output = New-APFConfigDeployment -ConfigurationType Registry -Name 'WinPkg' -Version '1.0' -DestinationFolder $Destination -CreateIntuneWinPackage -Confirm:$false
            Should -Invoke -ModuleName tcs.intune.packaging New-APFIntuneWinPackage -Times 1 -Exactly -ParameterFilter {
                $SourceFolder -eq (Join-Path -Path $Destination -ChildPath 'WinPkg') -and
                $SetupFile -eq (Join-Path -Path (Join-Path -Path $Destination -ChildPath 'WinPkg') -ChildPath 'Intune-I-MainInstaller.ps1') -and
                $PackageName -eq 'WinPkg'
            }
            $output -join "`n" | Should -Match 'WinPkg\.intunewin'
            $output -join "`n" | Should -Match ([regex]::Escape('%windir%\sysnative\WindowsPowerShell\v1.0\powershell.exe'))
        }
    }

    Context 'Pipeline input' {
        It 'Creates one package per piped object and never touches the destination folder itself' {
            Set-Content -Path (Join-Path -Path $Destination -ChildPath 'keep.txt') -Value 'keep'
            Mock -ModuleName tcs.intune.packaging Confirm-FolderOverwrite { $true }
            $null = @(
                [PSCustomObject]@{ Name = 'PipeOne'; Version = '1.0.0.0' }
                [PSCustomObject]@{ Name = 'PipeTwo'; Version = '2.0.0.0' }
            ) | New-APFConfigDeployment -ConfigurationType Custom -DestinationFolder $Destination -Confirm:$false
            Join-Path -Path $Destination -ChildPath 'keep.txt' | Should -Exist
            (Get-Content -Path (Join-Path -Path $Destination -ChildPath 'PipeOne/config.installer.json') -Raw | ConvertFrom-Json).version | Should -Be '1.0.0.0'
            (Get-Content -Path (Join-Path -Path $Destination -ChildPath 'PipeTwo/config.installer.json') -Raw | ConvertFrom-Json).version | Should -Be '2.0.0.0'
            Should -Invoke -ModuleName tcs.intune.packaging Confirm-FolderOverwrite -Times 0 -Exactly
        }

        It 'Reads DestinationFolder from each piped object' {
            $other = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString())
            $null = New-Item -Path $other -ItemType Directory
            $null = [PSCustomObject]@{ Name = 'PipeDest'; Version = '1.0'; DestinationFolder = $other } |
                New-APFConfigDeployment -ConfigurationType Custom -Confirm:$false
            Join-Path -Path $other -ChildPath 'PipeDest/Intune-Custom.ps1' | Should -Exist
        }
    }

    Context 'Existing package folder' {
        BeforeEach {
            $existing = Join-Path -Path $Destination -ChildPath 'Existing'
            $null = New-Item -Path $existing -ItemType Directory
            Set-Content -Path (Join-Path -Path $existing -ChildPath 'old.txt') -Value 'old'
        }

        It 'Leaves the folder unchanged and skips the package when the overwrite is declined' {
            Mock -ModuleName tcs.intune.packaging Confirm-FolderOverwrite { $false }
            Mock -ModuleName tcs.intune.packaging New-APFIntuneWinPackage { }
            $output = New-APFConfigDeployment -ConfigurationType Custom -Name 'Existing' -Version '1.0' -DestinationFolder $Destination -CreateIntuneWinPackage -Confirm:$false -WarningVariable apfWarning -WarningAction SilentlyContinue
            Join-Path -Path $existing -ChildPath 'old.txt' | Should -Exist
            Join-Path -Path $existing -ChildPath 'config.installer.json' | Should -Not -Exist
            "$apfWarning" | Should -Match 'was not changed'
            $output | Should -BeNullOrEmpty
            Should -Invoke -ModuleName tcs.intune.packaging New-APFIntuneWinPackage -Times 0 -Exactly
        }

        It 'Recreates the folder when the overwrite is confirmed' {
            Mock -ModuleName tcs.intune.packaging Confirm-FolderOverwrite { $true }
            $null = New-APFConfigDeployment -ConfigurationType Custom -Name 'Existing' -Version '1.0' -DestinationFolder $Destination -Confirm:$false
            Join-Path -Path $existing -ChildPath 'old.txt' | Should -Not -Exist
            Join-Path -Path $existing -ChildPath 'config.installer.json' | Should -Exist
        }
    }

    Context 'Names with special characters' {
        It 'Writes a name with quotes, a subexpression and dots into a detection script that parses' {
            $name = "O'Brien `$(Get-Date) v1..2"
            $null = New-APFConfigDeployment -ConfigurationType Custom -Name $name -Version '1.0' -DestinationFolder $Destination -Confirm:$false
            $folder = Join-Path -Path $Destination -ChildPath $name
            Test-Path -LiteralPath $folder -PathType Container | Should -BeTrue
            @(Get-ChildItem -LiteralPath $Destination).Count | Should -Be 1
            Get-AssignedString -Path (Join-Path -Path $folder -ChildPath 'Intune-D-Detection.ps1') -Variable 'AppName' | Should -BeExactly $name
            Get-AssignedString -Path (Join-Path -Path $folder -ChildPath 'Intune-D-Detection.ps1') -Variable 'Version' | Should -BeExactly '1.0'
        }

        It 'Writes the name into the detection script of the <Template> template so that it parses' -ForEach @(
            @{ Template = 'Application' }
            @{ Template = 'Files' }
            @{ Template = 'PowerShellProfile' }
            @{ Template = 'Registry' }
            @{ Template = 'WindowsFeatures' }
            @{ Template = 'script' }
            @{ Template = 'script-os' }
            @{ Template = 'standalone-application' }
            @{ Template = 'standalone-exe' }
        ) {
            # A double quote is not allowed in a folder name, so only the template text is checked here
            $name = "It's `$(x) `"quoted`" .."
            $package = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString())
            $null = New-Item -Path $package -ItemType Directory
            $detection = InModuleScope tcs.intune.packaging -Parameters @{ Template = $Template; Package = $package; Name = $name } {
                Copy-APFTemplate -Template $Template -Destination $Package
                $script = (Get-ChildItem -Path $Package -Filter 'Intune-D-*.ps1').FullName
                Set-TemplateToken -Path $script -Values @{ NAME = $Name; VERSION = '1.0' }
                $script
            }
            Get-AssignedString -Path $detection -Variable 'AppName' | Should -BeExactly $name
            Get-AssignedString -Path $detection -Variable 'Version' | Should -BeExactly '1.0'
        }

        It 'Rejects the name <Name>' -ForEach @(
            @{ Name = '..' }
            @{ Name = '.' }
            @{ Name = '../Outside' }
            @{ Name = 'a\b' }
            @{ Name = 'Wild[1]' }
            @{ Name = 'Star*' }
            @{ Name = 'Quote"d' }
            @{ Name = 'Trailing.' }
        ) {
            $null = New-APFConfigDeployment -ConfigurationType Custom -Name $Name -Version '1.0' -DestinationFolder $Destination -Confirm:$false -ErrorVariable apfError -ErrorAction SilentlyContinue
            $apfError | Should -Not -BeNullOrEmpty
            @(Get-ChildItem -LiteralPath $Destination).Count | Should -Be 0
            Join-Path -Path (Split-Path -Path $Destination -Parent) -ChildPath 'Outside' | Should -Not -Exist
        }
    }
}