diff --git a/DscResources/MSFT_MsiPackage/MSFT_MsiPackage.psm1 b/DscResources/MSFT_MsiPackage/MSFT_MsiPackage.psm1 new file mode 100644 index 0000000..446e38a --- /dev/null +++ b/DscResources/MSFT_MsiPackage/MSFT_MsiPackage.psm1 @@ -0,0 +1,1636 @@ +# Suppress Global Vars PSSA Error because $global:DSCMachineStatus must be allowed +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] +param() + +$errorActionPreference = 'Stop' +Set-StrictMode -Version 'Latest' + +# Import CommonResourceHelper for Get-LocalizedData +$script:dscResourcesFolderFilePath = Split-Path $PSScriptRoot -Parent +$script:commonResourceHelperFilePath = Join-Path -Path $script:dscResourcesFolderFilePath -ChildPath 'CommonResourceHelper.psm1' +Import-Module -Name $script:commonResourceHelperFilePath + +# Localized messages for verbose and error statements in this resource +$script:localizedData = Get-LocalizedData -ResourceName 'MSFT_MsiPackage' + +# Path to the directory where the files for a package from a file server will be downloaded to +$script:packageCacheLocation = "$env:ProgramData\Microsoft\Windows\PowerShell\Configuration\BuiltinProvCache\MSFT_MsiPackage" +$script:msiTools = $null + +<# + .SYNOPSIS + Retrieves the current state of the MSI file with the given Product ID. + + .PARAMETER ProductId + The ID of the MSI file to retrieve the state of, usually a GUID. + + .PARAMETER Path + Not used in Get-TargetResource. +#> +function Get-TargetResource +{ + [OutputType([Hashtable])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $ProductId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Path + ) + + $identifyingNumber = Convert-ProductIdToIdentifyingNumber -ProductId $ProductId + + $packageResourceResult = @{} + + $productEntry = Get-ProductEntry -IdentifyingNumber $identifyingNumber + + if ($null -eq $productEntry) + { + $packageResourceResult = @{ + Ensure = 'Absent' + ProductId = $identifyingNumber + } + + Write-Verbose -Message ($script:localizedData.GetTargetResourceNotFound -f $ProductId) + } + else + { + $packageResourceResult = Get-ProductEntryInfo -ProductEntry $productEntry + $packageResourceResult['ProductId'] = $identifyingNumber + $packageResourceResult['Ensure'] = 'Present' + + Write-Verbose -Message ($script:localizedData.GetTargetResourceFound -f $ProductId) + } + + return $packageResourceResult +} + +<# + .SYNOPSIS + Installs or uninstalls the MSI file at the given path. + + .PARAMETER ProductId + The identifying number used to find the package, usually a GUID. + + .PARAMETER Path + The path to the MSI file to install or uninstall. + + .PARAMETER Ensure + Indicates whether the given MSI file should be installed or uninstalled. + Set this property to Present to install the MSI, and Absent to uninstall + the MSI. + + .PARAMETER Arguments + The arguments to pass to the MSI package during installation or uninstallation + if needed. + + .PARAMETER Credential + The credential of a user account to be used to mount a UNC path if needed. + + .PARAMETER LogPath + The path to the log file to log the output from the MSI execution. + + .PARAMETER FileHash + The expected hash value of the MSI file at the given path. + + .PARAMETER HashAlgorithm + The algorithm used to generate the given hash value. + + .PARAMETER SignerSubject + The subject that should match the signer certificate of the digital signature of the MSI file. + + .PARAMETER SignerThumbprint + The certificate thumbprint that should match the signer certificate of the digital signature of the MSI file. + + .PARAMETER ServerCertificateValidationCallback + PowerShell code to be used to validate SSL certificates for paths using HTTPS. + + .PARAMETER RunAsCredential + The credential of a user account under which to run the installation or uninstallation of the MSI package. +#> +function Set-TargetResource +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $ProductId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Path, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [String] + $Arguments, + + [PSCredential] + [System.Management.Automation.Credential()] + $Credential, + + [String] + $LogPath, + + [String] + $FileHash, + + [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MD5', 'RIPEMD160')] + [String] + $HashAlgorithm = 'SHA256', + + [String] + $SignerSubject, + + [String] + $SignerThumbprint, + + [String] + $ServerCertificateValidationCallback, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.CredentialAttribute()] + $RunAsCredential + ) + + $uri = Convert-PathToUri -Path $Path + $identifyingNumber = Convert-ProductIdToIdentifyingNumber -ProductId $ProductId + + # Ensure that the actual file extension is checked if a query string is passed in + if ($null -ne $uri.LocalPath) + { + $uriLocalPath = (Split-Path -Path $uri.LocalPath -Leaf) + Assert-PathExtensionValid -Path $uriLocalPath + } + else + { + Assert-PathExtensionValid -Path $Path + } + + <# + Path gets overwritten in the download code path. Retain the user's original Path so as + to provide a more descriptive error message in case the install succeeds but the named + package can't be found on the system afterward. + #> + $originalPath = $Path + + Write-Verbose -Message $script:localizedData.PackageConfigurationStarting + + $psDrive = $null + $downloadedFileName = $null + + $exitCode = 0 + + try + { + if ($PSBoundParameters.ContainsKey('LogPath')) + { + New-LogFile -LogPath $LogPath + } + + # Download or mount file as necessary + if ($Ensure -eq 'Present') + { + $localPath = $Path + + if ($null -ne $uri.LocalPath) + { + $localPath = $uri.LocalPath + } + + if ($uri.IsUnc) + { + $psDriveArgs = @{ + Name = [Guid]::NewGuid() + PSProvider = 'FileSystem' + Root = Split-Path -Path $localPath + } + + if ($PSBoundParameters.ContainsKey('Credential')) + { + $psDriveArgs['Credential'] = $Credential + } + + $psDrive = New-PSDrive @psDriveArgs + $Path = Join-Path -Path $psDrive.Root -ChildPath (Split-Path -Path $localPath -Leaf) + } + elseif (@( 'http', 'https' ) -contains $uri.Scheme) + { + $outStream = $null + + try + { + if (-not (Test-Path -Path $script:packageCacheLocation -PathType 'Container')) + { + Write-Verbose -Message ($script:localizedData.CreatingCacheLocation) + $null = New-Item -Path $script:packageCacheLocation -ItemType 'Directory' + } + + $destinationPath = Join-Path -Path $script:packageCacheLocation -ChildPath (Split-Path -Path $localPath -Leaf) + + try + { + Write-Verbose -Message ($script:localizedData.CreatingTheDestinationCacheFile) + $outStream = New-Object -TypeName 'System.IO.FileStream' -ArgumentList @( $destinationPath, 'Create' ) + } + catch + { + # Should never happen since we own the cache directory + New-InvalidOperationException -Message ($script:localizedData.CouldNotOpenDestFile -f $destinationPath) -ErrorRecord $_ + } + + try + { + $responseStream = Get-WebRequestResponse -Uri $uri -ServerCertificateValidationCallback $ServerCertificateValidationCallback + + Copy-ResponseStreamToFileStream -ResponseStream $responseStream -FileStream $outStream + } + finally + { + if ($null -ne $responseStream) + { + Close-Stream -Stream $responseStream + } + } + } + finally + { + if ($null -ne $outStream) + { + Close-Stream -Stream $outStream + } + } + + Write-Verbose -Message ($script:localizedData.RedirectingPackagePathToCacheFileLocation) + $Path = $destinationPath + $downloadedFileName = $destinationPath + } + + # At this point the Path should be valid if this is an install case + if (-not (Test-Path -Path $Path -PathType 'Leaf')) + { + New-InvalidOperationException -Message ($script:localizedData.PathDoesNotExist -f $Path) + } + + Assert-FileValid -Path $Path -HashAlgorithm $HashAlgorithm -FileHash $FileHash -SignerSubject $SignerSubject -SignerThumbprint $SignerThumbprint + + # Check if the MSI package specifies the ProductCode, and if so make sure they match + $productCode = Get-MsiProductCode -Path $Path + + if ((-not [String]::IsNullOrEmpty($identifyingNumber)) -and ($identifyingNumber -ne $productCode)) + { + New-InvalidArgumentException -ArgumentName 'ProductId' -Message ($script:localizedData.InvalidId -f $identifyingNumber, $productCode) + } + } + + $exitCode = Start-MsiProcess -IdentifyingNumber $identifyingNumber -Path $Path -Ensure $Ensure -Arguments $Arguments -LogPath $LogPath -RunAsCredential $RunAsCredential + } + finally + { + if ($null -ne $psDrive) + { + $null = Remove-PSDrive -Name $psDrive -Force + } + } + + if ($null -ne $downloadedFileName) + { + <# + This is deliberately not in the finally block because we want to leave the downloaded + file on disk if an error occurred as a debugging aid for the user. + #> + $null = Remove-Item -Path $downloadedFileName + } + + <# + Check if a reboot is required, if so notify CA. The MSFT_ServerManagerTasks provider is + missing on some client SKUs (worked on both Server and Client Skus in Windows 10). + #> + $serverFeatureData = Invoke-CimMethod -Name 'GetServerFeature' ` + -Namespace 'root\microsoft\windows\servermanager' ` + -Class 'MSFT_ServerManagerTasks' ` + -Arguments @{ BatchSize = 256 } ` + -ErrorAction 'Ignore' + + $registryData = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name 'PendingFileRenameOperations' -ErrorAction 'Ignore' + + $rebootRequired = (($exitcode -eq 3010) -or ($exitcode -eq 1641) -or ($null -ne $registryData)) + + if (($serverFeatureData -and $serverFeatureData.RequiresReboot) -or $rebootRequired) + { + Write-Verbose $script:localizedData.MachineRequiresReboot + $global:DSCMachineStatus = 1 + } + elseif ($Ensure -eq 'Present') + { + $productEntry = Get-ProductEntry -IdentifyingNumber $identifyingNumber + + if ($null -eq $productEntry) + { + New-InvalidOperationException -Message ($script:localizedData.PostValidationError -f $originalPath) + } + } + + if ($Ensure -eq 'Present') + { + Write-Verbose -Message $script:localizedData.PackageInstalled + } + else + { + Write-Verbose -Message $script:localizedData.PackageUninstalled + } +} + +<# + .SYNOPSIS + Tests if the MSI file with the given product ID is installed or uninstalled. + + .PARAMETER ProductId + The identifying number used to find the package, usually a GUID. + + .PARAMETER Path + Not Used in Test-TargetResource + + .PARAMETER Ensure + Indicates whether the MSI file should be installed or uninstalled. + Set this property to Present if the MSI file should be installed. Set + this property to Absent if the MSI file should be uninstalled. + + .PARAMETER Arguments + Not Used in Test-TargetResource + + .PARAMETER Credential + Not Used in Test-TargetResource + + .PARAMETER LogPath + Not Used in Test-TargetResource + + .PARAMETER FileHash + Not Used in Test-TargetResource + + .PARAMETER HashAlgorithm + Not Used in Test-TargetResource + + .PARAMETER SignerSubject + Not Used in Test-TargetResource + + .PARAMETER SignerThumbprint + Not Used in Test-TargetResource + + .PARAMETER ServerCertificateValidationCallback + Not Used in Test-TargetResource + + .PARAMETER RunAsCredential + Not Used in Test-TargetResource +#> +function Test-TargetResource +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $ProductId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Path, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [String] + $Arguments, + + [PSCredential] + [System.Management.Automation.Credential()] + $Credential, + + [String] + $LogPath, + + [String] + $FileHash, + + [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MD5', 'RIPEMD160')] + [String] + $HashAlgorithm = 'SHA256', + + [String] + $SignerSubject, + + [String] + $SignerThumbprint, + + [String] + $ServerCertificateValidationCallback, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.CredentialAttribute()] + $RunAsCredential + ) + + $identifyingNumber = Convert-ProductIdToIdentifyingNumber -ProductId $ProductId + + $productEntry = Get-ProductEntry -IdentifyingNumber $identifyingNumber + + if ($null -ne $productEntry) + { + $displayName = Get-ProductEntryValue -ProductEntry $productEntry -Property 'DisplayName' + Write-Verbose -Message ($script:localizedData.PackageAppearsInstalled -f $displayName) + } + else + { + Write-Verbose -Message ($script:localizedData.PackageDoesNotAppearInstalled -f $ProductId) + } + + return (($null -ne $productEntry -and $Ensure -eq 'Present') -or ($null -eq $productEntry -and $Ensure -eq 'Absent')) +} + +<# + .SYNOPSIS + Asserts that the path extension is '.msi' + + .PARAMETER Path + The path to the file to validate the extension of. +#> +function Assert-PathExtensionValid +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Path + ) + + $pathExtension = [System.IO.Path]::GetExtension($Path) + Write-Verbose -Message ($script:localizedData.ThePathExtensionWasPathExt -f $pathExtension) + + if ($pathExtension.ToLower() -ne '.msi') + { + New-InvalidArgumentException -ArgumentName 'Path' -Message ($script:localizedData.InvalidBinaryType -f $Path) + } +} + +<# + .SYNOPSIS + Converts the given path to a URI and returns the URI object. + Throws an exception if the path's scheme as a URI is not valid. + + .PARAMETER Path + The path to the file to retrieve as a URI. +#> +function Convert-PathToUri +{ + [OutputType([Uri])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Path + ) + + try + { + $uri = [Uri]$Path + } + catch + { + New-InvalidArgumentException -ArgumentName 'Path' -Message ($script:localizedData.InvalidPath -f $Path) + } + + $validUriSchemes = @( 'file', 'http', 'https' ) + + if ($validUriSchemes -notcontains $uri.Scheme) + { + Write-Verbose -Message ($script:localizedData.TheUriSchemeWasUriScheme -f $uri.Scheme) + New-InvalidArgumentException -ArgumentName 'Path' -Message ($script:localizedData.InvalidPath -f $Path) + } + + return $uri +} + +<# + .SYNOPSIS + Converts the product ID to the identifying number format. + + .PARAMETER ProductId + The product ID to convert to an identifying number. +#> +function Convert-ProductIdToIdentifyingNumber +{ + [OutputType([String])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $ProductId + ) + + try + { + Write-Verbose -Message ($script:localizedData.ParsingProductIdAsAnIdentifyingNumber -f $ProductId) + $identifyingNumber = '{{{0}}}' -f [Guid]::Parse($ProductId).ToString().ToUpper() + + Write-Verbose -Message ($script:localizedData.ParsedProductIdAsIdentifyingNumber -f $ProductId, $identifyingNumber) + return $identifyingNumber + } + catch + { + New-InvalidArgumentException -ArgumentName 'ProductId' -Message ($script:localizedData.InvalidIdentifyingNumber -f $ProductId) + } +} + + +<# + .SYNOPSIS + Retrieves the product entry for the package with the given identifying number. + + .PARAMETER IdentifyingNumber + The identifying number of the product entry to retrieve. +#> +function Get-ProductEntry +{ + [OutputType([Microsoft.Win32.RegistryKey])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $IdentifyingNumber + ) + + $uninstallRegistryKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $uninstallRegistryKeyWow64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + + $productEntry = $null + + if (-not [String]::IsNullOrEmpty($IdentifyingNumber)) + { + $productEntryKeyLocation = Join-Path -Path $uninstallRegistryKey -ChildPath $IdentifyingNumber + $productEntry = Get-Item -Path $productEntryKeyLocation -ErrorAction 'SilentlyContinue' + + if ($null -eq $productEntry) + { + $productEntryKeyLocation = Join-Path -Path $uninstallRegistryKeyWow64 -ChildPath $IdentifyingNumber + $productEntry = Get-Item $productEntryKeyLocation -ErrorAction 'SilentlyContinue' + } + } + + return $productEntry +} + +<# + .SYNOPSIS + Retrieves the information for the given product entry and returns it as a hashtable. + + .PARAMETER ProductEntry + The product entry to retrieve the information for. +#> +function Get-ProductEntryInfo +{ + [OutputType([Hashtable])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [Microsoft.Win32.RegistryKey] + $ProductEntry + ) + + $installDate = Get-ProductEntryValue -ProductEntry $ProductEntry -Property 'InstallDate' + + if ($null -ne $installDate) + { + try + { + $installDate = '{0:d}' -f [DateTime]::ParseExact($installDate, 'yyyyMMdd',[System.Globalization.CultureInfo]::CurrentCulture).Date + } + catch + { + $installDate = $null + } + } + + $publisher = Get-ProductEntryValue -ProductEntry $ProductEntry -Property 'Publisher' + + $estimatedSizeInKB = Get-ProductEntryValue -ProductEntry $ProductEntry -Property 'EstimatedSize' + + if ($null -ne $estimatedSizeInKB) + { + $estimatedSizeInMB = $estimatedSizeInKB / 1024 + } + + $displayVersion = Get-ProductEntryValue -ProductEntry $ProductEntry -Property 'DisplayVersion' + + $comments = Get-ProductEntryValue -ProductEntry $ProductEntry -Property 'Comments' + + $displayName = Get-ProductEntryValue -ProductEntry $ProductEntry -Property 'DisplayName' + + $installSource = Get-ProductEntryValue -ProductEntry $ProductEntry -Property 'InstallSource' + + return @{ + Name = $displayName + InstallSource = $installSource + InstalledOn = $installDate + Size = $estimatedSizeInMB + Version = $displayVersion + PackageDescription = $comments + Publisher = $publisher + } +} + +<# + .SYNOPSIS + Retrieves the value of the given property for the given product entry. + This is a wrapper for unit testing. + + .PARAMETER ProductEntry + The product entry object to retrieve the property value from. + + .PARAMETER Property + The property to retrieve the value of from the product entry. +#> +function Get-ProductEntryValue +{ + [OutputType([Object])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [Microsoft.Win32.RegistryKey] + $ProductEntry, + + [Parameter(Mandatory = $true)] + [String] + $Property + ) + + return $ProductEntry.GetValue($Property) +} + +<# + .SYNOPSIS + Removes the file at the given path if it exists and creates a new file + to be written to. + + .PARAMETER LogPath + The path where the log file should be created. +#> +function New-LogFile +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $LogPath + ) + + try + { + <# + Pre-verify the log path exists and is writable ahead of time so the user won't + have to detect why the MSI log path doesn't exist. + #> + if (Test-Path -Path $LogPath) + { + $null = Remove-Item -Path $LogPath + } + + $null = New-Item -Path $LogPath -Type 'File' + } + catch + { + New-InvalidOperationException -Message ($script:localizedData.CouldNotOpenLog -f $LogPath) -ErrorRecord $_ + } +} + +<# + .SYNOPSIS + Retrieves the WebRequest response as a stream for the MSI file with the given URI. + + .PARAMETER Uri + The Uri to retrieve the WebRequest from. + + .PARAMETER ServerCertificationValidationCallback + The callback code to validate the SSL certificate for HTTPS URI schemes. +#> +function Get-WebRequestResponse +{ + [OutputType([System.IO.Stream])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [Uri] + $Uri, + + [String] + $ServerCertificateValidationCallback + ) + + try + { + $uriScheme = $Uri.Scheme + + Write-Verbose -Message ($script:localizedData.CreatingTheSchemeStream -f $uriScheme) + $webRequest = Get-WebRequest -Uri $Uri + + Write-Verbose -Message ($script:localizedData.SettingDefaultCredential) + $webRequest.Credentials = [System.Net.CredentialCache]::DefaultCredentials + $webRequest.AuthenticationLevel = [System.Net.Security.AuthenticationLevel]::None + + if ($uriScheme -eq 'http') + { + # Default value is MutualAuthRequested, which applies to the https scheme + Write-Verbose -Message ($script:localizedData.SettingAuthenticationLevel) + $webRequest.AuthenticationLevel = [System.Net.Security.AuthenticationLevel]::None + } + elseif ($uriScheme -eq 'https' -and -not [String]::IsNullOrEmpty($ServerCertificateValidationCallback)) + { + Write-Verbose -Message $script:localizedData.SettingCertificateValidationCallback + $webRequest.ServerCertificateValidationCallBack = (Get-ScriptBlock -FunctionName $ServerCertificateValidationCallback) + } + + Write-Verbose -Message ($script:localizedData.GettingTheSchemeResponseStream -f $uriScheme) + $responseStream = Get-WebRequestResponseStream -WebRequest $webRequest + + return $responseStream + } + catch + { + New-InvalidOperationException -Message ($script:localizedData.CouldNotGetResponseFromWebRequest -f $uriScheme, $Uri.OriginalString) -ErrorRecord $_ + } +} + +<# + .SYNOPSIS + Creates a WebRequst object based on the given Uri and returns it. + This is a wrapper for unit testing + + .PARAMETER Uri + The URI object to create the WebRequest from +#> +function Get-WebRequest +{ + [OutputType([System.Net.WebRequest])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [Uri] + $Uri + ) + + return [System.Net.WebRequest]::Create($Uri) +} + +<# + .SYNOPSIS + Retrieves the response stream from the given WebRequest object. + This is a wrapper for unit testing. + + .PARAMETER WebRequest + The WebRequest object to retrieve the response stream from. +#> +function Get-WebRequestResponseStream +{ + [OutputType([System.IO.Stream])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.Net.WebRequest] + $WebRequest + ) + + return (([System.Net.HttpWebRequest]$WebRequest).GetResponse()).GetResponseStream() +} + +<# + .SYNOPSIS + Converts the given function into a script block and returns it. + This is a wrapper for unit testing + + .PARAMETER Function + The name of the function to convert to a script block +#> +function Get-ScriptBlock +{ + [OutputType([ScriptBlock])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $FunctionName + ) + + return [ScriptBlock]::Create($FunctionName) +} + +<# + .SYNOPSIS + Copies the given response stream to the given file stream. + + .PARAMETER ResponseStream + The response stream to copy over. + + .PARAMETER FileStream + The file stream to copy to. +#> +function Copy-ResponseStreamToFileStream +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.IO.Stream] + $ResponseStream, + + [Parameter(Mandatory = $true)] + [System.IO.Stream] + $FileStream + ) + + try + { + Write-Verbose -Message ($script:localizedData.CopyingTheSchemeStreamBytesToTheDiskCache) + $null = $ResponseStream.CopyTo($FileStream) + $null = $ResponseStream.Flush() + $null = $FileStream.Flush() + } + catch + { + New-InvalidOperationException -Message ($script:localizedData.ErrorCopyingDataToFile) -ErrorRecord $_ + } +} + +<# + .SYNOPSIS + Closes the given stream. + Wrapper function for unit testing. + + .PARAMETER Stream + The stream to close. +#> +function Close-Stream +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.IO.Stream] + $Stream + ) + + $null = $Stream.Close() +} + +<# + .SYNOPSIS + Asserts that the file at the given path has a valid hash, signer thumbprint, and/or + signer subject. If only Path is provided, then this function will never throw. + If FileHash is provided and HashAlgorithm is not, then Sha-256 will be used as the hash + algorithm by default. + + .PARAMETER Path + The path to the file to check. + + .PARAMETER FileHash + The hash that should match the hash of the file. + + .PARAMETER HashAlgorithm + The algorithm to use to retrieve the file hash. + + .PARAMETER SignerThumbprint + The certificate thumbprint that should match the file's signer certificate. + + .PARAMETER SignerSubject + The certificate subject that should match the file's signer certificate. +#> +function Assert-FileValid +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $Path, + + [String] + $FileHash, + + [String] + $HashAlgorithm = 'SHA256', + + [String] + $SignerThumbprint, + + [String] + $SignerSubject + ) + + if (-not [String]::IsNullOrEmpty($FileHash)) + { + Assert-FileHashValid -Path $Path -Hash $FileHash -Algorithm $HashAlgorithm + } + + if (-not [String]::IsNullOrEmpty($SignerThumbprint) -or -not [String]::IsNullOrEmpty($SignerSubject)) + { + Assert-FileSignatureValid -Path $Path -Thumbprint $SignerThumbprint -Subject $SignerSubject + } +} + +<# + .SYNOPSIS + Asserts that the hash of the file at the given path matches the given hash. + + .PARAMETER Path + The path to the file to check the hash of. + + .PARAMETER Hash + The hash to check against. + + .PARAMETER Algorithm + The algorithm to use to retrieve the file's hash. + Default is 'Sha256' +#> +function Assert-FileHashValid +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $Path, + + [Parameter(Mandatory = $true)] + [String] + $Hash, + + [String] + $Algorithm = 'SHA256' + ) + + Write-Verbose -Message ($script:localizedData.CheckingFileHash -f $Path, $Hash, $Algorithm) + + $fileHash = Get-FileHash -LiteralPath $Path -Algorithm $Algorithm + + if ($fileHash.Hash -ne $Hash) + { + New-InvalidArgumentException -ArgumentName 'FileHash' -Message ($script:localizedData.InvalidFileHash -f $Path, $Hash, $Algorithm) + } +} + +<# + .SYNOPSIS + Asserts that the signature of the file at the given path is valid. + + .PARAMETER Path + The path to the file to check the signature of + + .PARAMETER Thumbprint + The certificate thumbprint that should match the file's signer certificate. + + .PARAMETER Subject + The certificate subject that should match the file's signer certificate. +#> +function Assert-FileSignatureValid +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $Path, + + [String] + $Thumbprint, + + [String] + $Subject + ) + + Write-Verbose -Message ($script:localizedData.CheckingFileSignature -f $Path) + + $signature = Get-AuthenticodeSignature -LiteralPath $Path + + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) + { + New-InvalidArgumentException -ArgumentName 'Path' -Message ($script:localizedData.InvalidFileSignature -f $Path, $signature.Status) + } + else + { + Write-Verbose -Message ($script:localizedData.FileHasValidSignature -f $Path, $signature.SignerCertificate.Thumbprint, $signature.SignerCertificate.Subject) + } + + if (-not [String]::IsNullOrEmpty($Subject) -and ($signature.SignerCertificate.Subject -notlike $Subject)) + { + New-InvalidArgumentException -ArgumentName 'SignerSubject' -Message ($script:localizedData.WrongSignerSubject -f $Path, $Subject) + } + + if (-not [String]::IsNullOrEmpty($Thumbprint) -and ($signature.SignerCertificate.Thumbprint -ne $Thumbprint)) + { + New-InvalidArgumentException -ArgumentName 'SignerThumbprint' -Message ($script:localizedData.WrongSignerThumbprint -f $Path, $Thumbprint) + } +} + +<# + .SYNOPSIS + Starts the given MSI installation or uninstallation either as a process or + under a user credential if RunAsCredential is specified. + + .PARAMETER IdentifyingNumber + The identifying number used to find the package. + + .PARAMETER Path + The path to the MSI file to install or uninstall. + + .PARAMETER Ensure + Indicates whether the given MSI file should be installed or uninstalled. + + .PARAMETER Arguments + The arguments to pass to the MSI package. + + .PARAMETER LogPath + The path to the log file to log the output from the MSI execution. + + .PARAMETER RunAsCredential + The credential of a user account under which to run the installation or uninstallation. +#> +function Start-MsiProcess +{ + [OutputType([Int32])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $IdentifyingNumber, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Path, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [String] + $Arguments, + + [String] + $LogPath, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.CredentialAttribute()] + $RunAsCredential + ) + + $startInfo = New-Object -TypeName 'System.Diagnostics.ProcessStartInfo' + + # Necessary for I/O redirection + $startInfo.UseShellExecute = $false + + $startInfo.FileName = "$env:winDir\system32\msiexec.exe" + + if ($Ensure -eq 'Present') + { + $startInfo.Arguments = '/i "{0}"' -f $Path + } + # Ensure -eq 'Absent' + else + { + $productEntry = Get-ProductEntry -IdentifyingNumber $identifyingNumber + + $id = Split-Path -Path $productEntry.Name -Leaf + $startInfo.Arguments = ('/x{0}' -f $id) + } + + if (-not [String]::IsNullOrEmpty($LogPath)) + { + $startInfo.Arguments += (' /log "{0}"' -f $LogPath) + } + + $startInfo.Arguments += ' /quiet /norestart' + + if (-not [String]::IsNullOrEmpty($Arguments)) + { + # Append any specified arguments with a space + $startInfo.Arguments += (' {0}' -f $Arguments) + } + + Write-Verbose -Message ($script:localizedData.StartingWithStartInfoFileNameStartInfoArguments -f $startInfo.FileName, $startInfo.Arguments) + + $exitCode = 0 + + try + { + if (-not [String]::IsNullOrEmpty($RunAsCredential)) + { + $commandLine = ('"{0}" {1}' -f $startInfo.FileName, $startInfo.Arguments) + $exitCode = Invoke-PInvoke -CommandLine $commandLine -RunAsCredential $RunAsCredential + } + else + { + $process = New-Object -TypeName 'System.Diagnostics.Process' + $process.StartInfo = $startInfo + $exitCode = Invoke-Process -Process $process + } + } + catch + { + New-InvalidOperationException -Message ($script:localizedData.CouldNotStartProcess -f $Path) -ErrorRecord $_ + } + + return $exitCode +} + +<# + .SYNOPSIS + Runs a process as the specified user via PInvoke. Returns the exitCode that + PInvoke returns. + + .PARAMETER CommandLine + The command line (including arguments) of the process to start. + + .PARAMETER RunAsCredential + The user credential to start the process as. +#> +function Invoke-PInvoke +{ + [OutputType([System.Int32])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $CommandLine, + + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential] + [System.Management.Automation.CredentialAttribute()] + $RunAsCredential + ) + + Register-PInvoke + [System.Int32] $exitCode = 0 + + $null = [Source.NativeMethods]::CreateProcessAsUser($CommandLine, ` + $RunAsCredential.GetNetworkCredential().Domain, ` + $RunAsCredential.GetNetworkCredential().UserName, ` + $RunAsCredential.GetNetworkCredential().Password, ` + [ref] $exitCode + ) + + return $exitCode +} + +<# + .SYNOPSIS + Starts and waits for a process. + + .PARAMETER Process + The System.Diagnositics.Process object to start. +#> +function Invoke-Process +{ + [OutputType([System.Int32])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.Diagnostics.Process] + $Process + ) + + $null = $Process.Start() + + $null = $Process.WaitForExit() + return $Process.ExitCode +} + +<# + .SYNOPSIS + Retrieves product code from the MSI at the given path. + + .PARAMETER Path + The path to the MSI to retrieve the product code from. +#> +function Get-MsiProductCode +{ + [OutputType([String])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Path + ) + + $msiTools = Get-MsiTool + + $productCode = $msiTools::GetProductCode($Path) + + return $productCode +} + +<# + .SYNOPSIS + Retrieves the MSI tools type. +#> +function Get-MsiTool +{ + [OutputType([System.Type])] + [CmdletBinding()] + param () + + # Check if the variable is already defined + if ($null -ne $script:msiTools) + { + return $script:msiTools + } + + $msiToolsCodeDefinition = @' + [DllImport("msi.dll", CharSet = CharSet.Unicode, PreserveSig = true, SetLastError = true, ExactSpelling = true)] + private static extern UInt32 MsiOpenPackageExW(string szPackagePath, int dwOptions, out IntPtr hProduct); + [DllImport("msi.dll", CharSet = CharSet.Unicode, PreserveSig = true, SetLastError = true, ExactSpelling = true)] + private static extern uint MsiCloseHandle(IntPtr hAny); + [DllImport("msi.dll", CharSet = CharSet.Unicode, PreserveSig = true, SetLastError = true, ExactSpelling = true)] + private static extern uint MsiGetPropertyW(IntPtr hAny, string name, StringBuilder buffer, ref int bufferLength); + private static string GetPackageProperty(string msi, string property) + { + IntPtr MsiHandle = IntPtr.Zero; + try + { + var res = MsiOpenPackageExW(msi, 1, out MsiHandle); + if (res != 0) + { + return null; + } + int length = 256; + var buffer = new StringBuilder(length); + res = MsiGetPropertyW(MsiHandle, property, buffer, ref length); + return buffer.ToString(); + } + finally + { + if (MsiHandle != IntPtr.Zero) + { + MsiCloseHandle(MsiHandle); + } + } + } + public static string GetProductCode(string msi) + { + return GetPackageProperty(msi, "ProductCode"); + } + public static string GetProductName(string msi) + { + return GetPackageProperty(msi, "ProductName"); + } +'@ + + # Check if the the type is already defined + if (([System.Management.Automation.PSTypeName]'Microsoft.Windows.DesiredStateConfiguration.MsiPackageResource.MsiTools').Type) + { + $script:msiTools = ([System.Management.Automation.PSTypeName]'Microsoft.Windows.DesiredStateConfiguration.MsiPackageResource.MsiTools').Type + } + else + { + $script:msiTools = Add-Type ` + -Namespace 'Microsoft.Windows.DesiredStateConfiguration.MsiPackageResource' ` + -Name 'MsiTools' ` + -Using 'System.Text' ` + -MemberDefinition $msiToolsCodeDefinition ` + -PassThru + } + + return $script:msiTools +} + +<# + .SYNOPSIS + Registers PInvoke to run a process as a user. +#> +function Register-PInvoke +{ + [CmdletBinding()] + param () + + $programSource = @' + using System; + using System.Collections.Generic; + using System.Text; + using System.Security; + using System.Runtime.InteropServices; + using System.Diagnostics; + using System.Security.Principal; + using System.ComponentModel; + using System.IO; + namespace Source + { + [SuppressUnmanagedCodeSecurity] + public static class NativeMethods + { + //The following structs and enums are used by the various Win32 API's that are used in the code below + [StructLayout(LayoutKind.Sequential)] + public struct STARTUPINFO + { + public Int32 cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public Int32 dwX; + public Int32 dwY; + public Int32 dwXSize; + public Int32 dwXCountChars; + public Int32 dwYCountChars; + public Int32 dwFillAttribute; + public Int32 dwFlags; + public Int16 wShowWindow; + public Int16 cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public Int32 dwProcessID; + public Int32 dwThreadID; + } + [Flags] + public enum LogonType + { + LOGON32_LOGON_INTERACTIVE = 2, + LOGON32_LOGON_NETWORK = 3, + LOGON32_LOGON_BATCH = 4, + LOGON32_LOGON_SERVICE = 5, + LOGON32_LOGON_UNLOCK = 7, + LOGON32_LOGON_NETWORK_CLEARTEXT = 8, + LOGON32_LOGON_NEW_CREDENTIALS = 9 + } + [Flags] + public enum LogonProvider + { + LOGON32_PROVIDER_DEFAULT = 0, + LOGON32_PROVIDER_WINNT35, + LOGON32_PROVIDER_WINNT40, + LOGON32_PROVIDER_WINNT50 + } + [StructLayout(LayoutKind.Sequential)] + public struct SECURITY_ATTRIBUTES + { + public Int32 Length; + public IntPtr lpSecurityDescriptor; + public bool bInheritHandle; + } + public enum SECURITY_IMPERSONATION_LEVEL + { + SecurityAnonymous, + SecurityIdentification, + SecurityImpersonation, + SecurityDelegation + } + public enum TOKEN_TYPE + { + TokenPrimary = 1, + TokenImpersonation + } + [StructLayout(LayoutKind.Sequential, Pack = 1)] + internal struct TokPriv1Luid + { + public int Count; + public long Luid; + public int Attr; + } + public const int GENERIC_ALL_ACCESS = 0x10000000; + public const int CREATE_NO_WINDOW = 0x08000000; + internal const int SE_PRIVILEGE_ENABLED = 0x00000002; + internal const int TOKEN_QUERY = 0x00000008; + internal const int TOKEN_ADJUST_PRIVILEGES = 0x00000020; + internal const string SE_INCRASE_QUOTA = "SeIncreaseQuotaPrivilege"; + [DllImport("kernel32.dll", + EntryPoint = "CloseHandle", SetLastError = true, + CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)] + public static extern bool CloseHandle(IntPtr handle); + [DllImport("advapi32.dll", + EntryPoint = "CreateProcessAsUser", SetLastError = true, + CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)] + public static extern bool CreateProcessAsUser( + IntPtr hToken, + string lpApplicationName, + string lpCommandLine, + ref SECURITY_ATTRIBUTES lpProcessAttributes, + ref SECURITY_ATTRIBUTES lpThreadAttributes, + bool bInheritHandle, + Int32 dwCreationFlags, + IntPtr lpEnvrionment, + string lpCurrentDirectory, + ref STARTUPINFO lpStartupInfo, + ref PROCESS_INFORMATION lpProcessInformation + ); + [DllImport("advapi32.dll", EntryPoint = "DuplicateTokenEx")] + public static extern bool DuplicateTokenEx( + IntPtr hExistingToken, + Int32 dwDesiredAccess, + ref SECURITY_ATTRIBUTES lpThreadAttributes, + Int32 ImpersonationLevel, + Int32 dwTokenType, + ref IntPtr phNewToken + ); + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern Boolean LogonUser( + String lpszUserName, + String lpszDomain, + String lpszPassword, + LogonType dwLogonType, + LogonProvider dwLogonProvider, + out IntPtr phToken + ); + [DllImport("advapi32.dll", ExactSpelling = true, SetLastError = true)] + internal static extern bool AdjustTokenPrivileges( + IntPtr htok, + bool disall, + ref TokPriv1Luid newst, + int len, + IntPtr prev, + IntPtr relen + ); + [DllImport("kernel32.dll", ExactSpelling = true)] + internal static extern IntPtr GetCurrentProcess(); + [DllImport("advapi32.dll", ExactSpelling = true, SetLastError = true)] + internal static extern bool OpenProcessToken( + IntPtr h, + int acc, + ref IntPtr phtok + ); + [DllImport("kernel32.dll", ExactSpelling = true)] + internal static extern int WaitForSingleObject( + IntPtr h, + int milliseconds + ); + [DllImport("kernel32.dll", ExactSpelling = true)] + internal static extern bool GetExitCodeProcess( + IntPtr h, + out int exitcode + ); + [DllImport("advapi32.dll", SetLastError = true)] + internal static extern bool LookupPrivilegeValue( + string host, + string name, + ref long pluid + ); + public static void CreateProcessAsUser(string strCommand, string strDomain, string strName, string strPassword, ref int ExitCode ) + { + var hToken = IntPtr.Zero; + var hDupedToken = IntPtr.Zero; + TokPriv1Luid tp; + var pi = new PROCESS_INFORMATION(); + var sa = new SECURITY_ATTRIBUTES(); + sa.Length = Marshal.SizeOf(sa); + Boolean bResult = false; + try + { + bResult = LogonUser( + strName, + strDomain, + strPassword, + LogonType.LOGON32_LOGON_BATCH, + LogonProvider.LOGON32_PROVIDER_DEFAULT, + out hToken + ); + if (!bResult) + { + throw new Win32Exception("Logon error #" + Marshal.GetLastWin32Error().ToString()); + } + IntPtr hproc = GetCurrentProcess(); + IntPtr htok = IntPtr.Zero; + bResult = OpenProcessToken( + hproc, + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, + ref htok + ); + if(!bResult) + { + throw new Win32Exception("Open process token error #" + Marshal.GetLastWin32Error().ToString()); + } + tp.Count = 1; + tp.Luid = 0; + tp.Attr = SE_PRIVILEGE_ENABLED; + bResult = LookupPrivilegeValue( + null, + SE_INCRASE_QUOTA, + ref tp.Luid + ); + if(!bResult) + { + throw new Win32Exception("Lookup privilege error #" + Marshal.GetLastWin32Error().ToString()); + } + bResult = AdjustTokenPrivileges( + htok, + false, + ref tp, + 0, + IntPtr.Zero, + IntPtr.Zero + ); + if(!bResult) + { + throw new Win32Exception("Token elevation error #" + Marshal.GetLastWin32Error().ToString()); + } + bResult = DuplicateTokenEx( + hToken, + GENERIC_ALL_ACCESS, + ref sa, + (int)SECURITY_IMPERSONATION_LEVEL.SecurityIdentification, + (int)TOKEN_TYPE.TokenPrimary, + ref hDupedToken + ); + if(!bResult) + { + throw new Win32Exception("Duplicate Token error #" + Marshal.GetLastWin32Error().ToString()); + } + var si = new STARTUPINFO(); + si.cb = Marshal.SizeOf(si); + si.lpDesktop = ""; + bResult = CreateProcessAsUser( + hDupedToken, + null, + strCommand, + ref sa, + ref sa, + false, + 0, + IntPtr.Zero, + null, + ref si, + ref pi + ); + if(!bResult) + { + throw new Win32Exception("Create process as user error #" + Marshal.GetLastWin32Error().ToString()); + } + int status = WaitForSingleObject(pi.hProcess, -1); + if(status == -1) + { + throw new Win32Exception("Wait during create process failed user error #" + Marshal.GetLastWin32Error().ToString()); + } + bResult = GetExitCodeProcess(pi.hProcess, out ExitCode); + if(!bResult) + { + throw new Win32Exception("Retrieving status error #" + Marshal.GetLastWin32Error().ToString()); + } + } + finally + { + if (pi.hThread != IntPtr.Zero) + { + CloseHandle(pi.hThread); + } + if (pi.hProcess != IntPtr.Zero) + { + CloseHandle(pi.hProcess); + } + if (hDupedToken != IntPtr.Zero) + { + CloseHandle(hDupedToken); + } + } + } + } + } +'@ + $null = Add-Type -TypeDefinition $programSource -ReferencedAssemblies 'System.ServiceProcess' +} diff --git a/DscResources/MSFT_MsiPackage/MSFT_MsiPackage.schema.mof b/DscResources/MSFT_MsiPackage/MSFT_MsiPackage.schema.mof new file mode 100644 index 0000000..45cce7d --- /dev/null +++ b/DscResources/MSFT_MsiPackage/MSFT_MsiPackage.schema.mof @@ -0,0 +1,23 @@ +[ClassVersion("1.0.0"),FriendlyName("MsiPackage")] +class MSFT_MsiPackage : OMI_BaseResource +{ + [Key, Description("The identifying number used to find the package, usually a GUID.")] String ProductId; + [Required, Description("The path to the MSI file that should be installed or uninstalled.")] String Path; + [Write, Description("Specifies whether or not the MSI file should be installed or uninstalled."), ValueMap{"Present", "Absent"}, Values{"Present", "Absent"}] String Ensure; + [Write, Description("The arguments to be passed to the MSI package during installation or uninstallation.")] String Arguments; + [Write, Description("The credential of a user account to be used to mount a UNC path if needed."), EmbeddedInstance("MSFT_Credential")] String Credential; + [Write, Description("The path to the log file to log the output from the MSI execution.")] String LogPath; + [Write, Description("The expected hash value of the MSI file at the given path.")] String FileHash; + [Write, Description("The algorithm used to generate the given hash value."), ValueMap{"SHA1", "SHA256", "SHA384", "SHA512", "MD5", "RIPEMD160"}, Values{"SHA1", "SHA256", "SHA384", "SHA512", "MD5", "RIPEMD160"}] String HashAlgorithm; + [Write, Description("The subject that should match the signer certificate of the digital signature of the MSI file.")] String SignerSubject; + [Write, Description("The certificate thumbprint that should match the signer certificate of the digital signature of the MSI file.")] String SignerThumbprint; + [Write, Description("PowerShell code that should be used to validate SSL certificates for paths using HTTPS.")] String ServerCertificateValidationCallback; + [Write, Description("The credential of a user account under which to run the installation or uninstallation of the MSI package."), EmbeddedInstance("MSFT_Credential")] String RunAsCredential; + [Read, Description("The display name of the MSI package.")] String Name; + [Read, Description("The path to the MSI package.")] String InstallSource; + [Read, Description("The date that the MSI package was installed on or serviced on, whichever is later.")] String InstalledOn; + [Read, Description("The size of the MSI package in MB.")] UInt32 Size; + [Read, Description("The version number of the MSI package.")] String Version; + [Read, Description("The description of the MSI package.")] String PackageDescription; + [Read, Description("The publisher of the MSI package.")] String Publisher; +}; diff --git a/DscResources/MSFT_MsiPackage/en-US/MSFT_MsiPackage.schema.mfl b/DscResources/MSFT_MsiPackage/en-US/MSFT_MsiPackage.schema.mfl new file mode 100644 index 0000000..a629c93 Binary files /dev/null and b/DscResources/MSFT_MsiPackage/en-US/MSFT_MsiPackage.schema.mfl differ diff --git a/DscResources/MSFT_MsiPackage/en-US/MSFT_MsiPackage.strings.psd1 b/DscResources/MSFT_MsiPackage/en-US/MSFT_MsiPackage.strings.psd1 new file mode 100644 index 0000000..ecb56be --- /dev/null +++ b/DscResources/MSFT_MsiPackage/en-US/MSFT_MsiPackage.strings.psd1 @@ -0,0 +1,44 @@ +# Localized resources for MSFT_MsiPackage + +ConvertFrom-StringData @' + CheckingFileHash = Checking file '{0}' for expected {2} hash value of {1} + CheckingFileSignature = Checking file '{0}' for valid digital signature + CopyingTheSchemeStreamBytesToTheDiskCache = Copying the stream bytes to the disk cache + CouldNotGetResponseFromWebRequest = An error occurred while trying to get the {0} response for file {1} + CouldNotOpenDestFile = Could not open the file {0} for writing + CouldNotOpenLog = The specified LogPath ({0}) could not be opened + CouldNotStartProcess = The process {0} could not be started + CreatingCacheLocation = Creating cache location + CreatingTheDestinationCacheFile = Creating the destination cache file + CreatingTheSchemeStream = Creating the {0} stream + ErrorCopyingDataToFile = Encountered an error while copying the response to the output stream + FileHasValidSignature = File '{0}' contains a valid digital signature. Signer Thumbprint: {1}, Subject: {2} + GetTargetResourceFound = Successfully retrieved package {0} + GetTargetResourceNotFound = Unable to find package: {0} + GettingTheSchemeResponseStream = Getting the {0} response stream + InvalidBinaryType = The specified Path ({0}) does not appear to specify an MSI file and as such is not supported + InvalidFileHash = File '{0}' does not match expected {2} hash value of {1} + InvalidFileSignature = File '{0}' does not have a valid Authenticode signature. Status: {1} + InvalidId = The specified IdentifyingNumber ({0}) does not match the IdentifyingNumber ({1}) in the MSI file + InvalidIdentifyingNumber = The specified IdentifyingNumber ({0}) is not a valid GUID + InvalidPath = The specified Path ({0}) is not in a valid format. Valid formats are local paths, UNC, HTTP, and HTTPS + MachineRequiresReboot = The machine requires a reboot + PackageAppearsInstalled = The package {0} is installed + PackageConfigurationStarting = Package configuration starting + PackageDoesNotAppearInstalled = The package {0} is not installed + PathDoesNotExist = The given Path ({0}) could not be found + PackageInstalled = Package has been installed + PackageUninstalled = Package has been uninstalled + ParsedProductIdAsIdentifyingNumber = Parsed {0} as {1} + ParsingProductIdAsAnIdentifyingNumber = Parsing {0} as an identifyingNumber + PostValidationError = Package from {0} was installed, but the specified ProductId does not match package details + RedirectingPackagePathToCacheFileLocation = Redirecting package path to cache file location + SettingAuthenticationLevel = Setting authentication level to None + SettingCertificateValidationCallback = Assigning user-specified certificate verification callback + SettingDefaultCredential = Setting default credential + StartingWithStartInfoFileNameStartInfoArguments = Starting {0} with {1} + ThePathExtensionWasPathExt = The path extension was {0} + TheUriSchemeWasUriScheme = The uri scheme was {0} + WrongSignerSubject = File '{0}' was not signed by expected signer subject '{1}' + WrongSignerThumbprint = File '{0}' was not signed by expected signer certificate thumbprint '{1}' +'@ diff --git a/Examples/Sample_MsiPackage_InstallPackageFromFile.ps1 b/Examples/Sample_MsiPackage_InstallPackageFromFile.ps1 new file mode 100644 index 0000000..3cd02a6 --- /dev/null +++ b/Examples/Sample_MsiPackage_InstallPackageFromFile.ps1 @@ -0,0 +1,28 @@ +<# + .SYNOPSIS + Installs the MSI file with the product ID: '{DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F}' + at the path: 'file://Examples/example.msi'. + + Note that the MSI file with the given product ID must already exist at the specified path. + The product ID and path value in this file are provided for example purposes only and will + need to be replaced with valid values. + + You can run the following command to get a list of all available MSIs on + your system with the correct Path (LocalPackage) and product ID (IdentifyingNumber): + + Get-WmiObject Win32_Product | Format-Table IdentifyingNumber, Name, LocalPackage +#> +Configuration Sample_MsiPackage_InstallPackageFromFile +{ + Import-DscResource -ModuleName 'PSDscResources' + + Node localhost + { + MsiPackage MsiPackage1 + { + ProductId = '{DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F}' + Path = 'file://Examples/example.msi' + Ensure = 'Present' + } + } +} diff --git a/Examples/Sample_MsiPackage_InstallPackageFromHttp.ps1 b/Examples/Sample_MsiPackage_InstallPackageFromHttp.ps1 new file mode 100644 index 0000000..65413c0 --- /dev/null +++ b/Examples/Sample_MsiPackage_InstallPackageFromHttp.ps1 @@ -0,0 +1,23 @@ +<# + .SYNOPSIS + Installs the MSI file with the product ID: '{DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F}' + at the path: 'http://Examples/example.msi'. + + Note that the MSI file with the given product ID must already exist on the server. + The product ID and path value in this file are provided for example purposes only and will + need to be replaced with valid values. +#> +Configuration Sample_MsiPackage_InstallPackageFromHttp +{ + Import-DscResource -ModuleName 'PSDscResources' + + Node localhost + { + MsiPackage MsiPackage1 + { + ProductId = '{DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F}' + Path = 'http://Examples/example.msi' + Ensure = 'Present' + } + } +} diff --git a/Examples/Sample_MsiPackage_UninstallPackageFromFile.ps1 b/Examples/Sample_MsiPackage_UninstallPackageFromFile.ps1 new file mode 100644 index 0000000..de0188f --- /dev/null +++ b/Examples/Sample_MsiPackage_UninstallPackageFromFile.ps1 @@ -0,0 +1,28 @@ +<# + .SYNOPSIS + Uninstalls the MSI file with the product ID: '{DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F}' + at the path: 'file://Examples/example.msi'. + + Note that the MSI file with the given product ID must already exist at the specified path. + The product ID and path value in this file are provided for example purposes only and will + need to be replaced with valid values. + + You can run the following command to get a list of all available MSIs on + your system with the correct Path (LocalPackage) and product ID (IdentifyingNumber): + + Get-WmiObject Win32_Product | Format-Table IdentifyingNumber, Name, LocalPackage +#> +Configuration Sample_MsiPackage_UninstallPackageFromFile +{ + Import-DscResource -ModuleName 'PSDscResources' + + Node localhost + { + MsiPackage MsiPackage1 + { + ProductId = '{DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F}' + Path = 'file://Examples/example.msi' + Ensure = 'Absent' + } + } +} diff --git a/Examples/Sample_MsiPackage_UnstallPackageFromHttps.ps1 b/Examples/Sample_MsiPackage_UnstallPackageFromHttps.ps1 new file mode 100644 index 0000000..f35892a --- /dev/null +++ b/Examples/Sample_MsiPackage_UnstallPackageFromHttps.ps1 @@ -0,0 +1,23 @@ +<# + .SYNOPSIS + Uninstalls the MSI file with the product ID: '{DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F}' + at the path: 'https://Examples/example.msi'. + + Note that the MSI file with the given product ID must already exist on the server. + The product ID and path value in this file are provided for example purposes only and will + need to be replaced with valid values. +#> +Configuration Sample_MsiPackage_UninstallPackageFromHttps +{ + Import-DscResource -ModuleName 'PSDscResources' + + Node localhost + { + MsiPackage MsiPackage1 + { + ProductId = '{DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F}' + Path = 'https://Examples/example.msi' + Ensure = 'Absent' + } + } +} diff --git a/PSDscResources.psd1 b/PSDscResources.psd1 index 0496628..88cb646 100644 --- a/PSDscResources.psd1 +++ b/PSDscResources.psd1 @@ -73,7 +73,7 @@ VariablesToExport = '*' AliasesToExport = @() # DSC resources to export from this module -DscResourcesToExport = @( 'Archive', 'Environment', 'Group', 'GroupSet', 'Registry', 'Script', 'Service', 'ServiceSet', 'User', 'WindowsFeature', 'WindowsFeatureSet', 'WindowsOptionalFeature', 'WindowsOptionalFeatureSet', 'WindowsPackageCab', 'WindowsProcess', 'ProcessSet' ) +DscResourcesToExport = @( 'Archive', 'Environment', 'Group', 'GroupSet', 'MsiPackage', 'Registry', 'Script', 'Service', 'ServiceSet', 'User', 'WindowsFeature', 'WindowsFeatureSet', 'WindowsOptionalFeature', 'WindowsOptionalFeatureSet', 'WindowsPackageCab', 'WindowsProcess', 'ProcessSet' ) # List of all modules packaged with this module # ModuleList = @() diff --git a/README.md b/README.md index 75ba4cb..3adbb62 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Please check out the common DSC Resources [contributing guidelines](https://gith * [Environment](#environment): Provides a mechanism to configure and manage environment variables for a machine or process. * [Group](#group): Provides a mechanism to manage local groups on a target node. * [GroupSet](#groupset): Provides a mechanism to configure and manage multiple Group resources with common settings but different names. +* [MsiPackage](#msipackage): Provides a mechanism to install and uninstall MSI packages. * [Registry](#registry): Provides a mechanism to manage registry keys and values on a target node. * [Script](#script): Provides a mechanism to run PowerShell script blocks on a target node. * [Service](#service): Provides a mechanism to configure and manage Windows services on a target node. @@ -177,6 +178,46 @@ None * [Add members to multiple groups](https://github.com/PowerShell/PSDscResources/blob/master/Examples/Sample_GroupSet_AddMembers.ps1) +### MsiPackage + +Provides a mechanism to install and uninstall MSI packages. + +#### Requirements + +None + +#### Parameters + +* **[String] ProductId** _(Key)_: The identifying number used to find the package, usually a GUID. +* **[String] Path** _(Required)_: The path to the MSI file that should be installed or uninstalled. +* **[String] Ensure** _(Write)_: Specifies whether or not the MSI file should be installed or not. To install the MSI file, specify this property as Present. To uninstall the .msi file, specify this property as Absent. The default value is Present. { *Present* | Absent }. +* **[String] Arguments** _(Write)_: The arguments to be passed to the MSI package during installation or uninstallation if needed. +* **[System.Management.Automation.PSCredential] Credential** _(Write)_: The credential of a user account to be used to mount a UNC path if needed. +* **[String] LogPath** _(Write)_: The path to the log file to log the output from the MSI execution. +* **[String] FileHash** _(Write)_: The expected hash value of the MSI file at the given path. +* **[String] HashAlgorithm** _(Write)_: The algorithm used to generate the given hash value. +* **[String] SignerSubject** _(Write)_: The subject that should match the signer certificate of the digital signature of the MSI file. +* **[String] SignerThumbprint** _(Write)_: The certificate thumbprint that should match the signer certificate of the digital signature of the MSI file. +* **[String] ServerCertificateValidationCallback** _(Write)_: PowerShell code that should be used to validate SSL certificates for paths using HTTPS. +* **[System.Management.Automation.PSCredential] RunAsCredential** _(Write)_: The credential of a user account under which to run the installation or uninstallation of the MSI package. + +#### Read-Only Properties from Get-TargetResource + +* **[String] Name** _(Read)_: The display name of the MSI package. +* **[String] InstallSource** _(Read)_: The path to the MSI package. +* **[String] InstalledOn** _(Read)_: The date that the MSI package was installed on or serviced on, whichever is later. +* **[UInt32] Size** _(Read)_: The size of the MSI package in MB. +* **[String] Version** _(Read)_: The version number of the MSI package. +* **[String] PackageDescription** _(Read)_: The description of the MSI package. +* **[String] Publisher** _(Read)_: The publisher of the MSI package. + +#### Examples + +* [Install the MSI file with the given ID at the given Path](https://github.com/PowerShell/PSDscResources/blob/dev/Examples/Sample_MsiPackage_InstallPackageFromFile.ps1) +* [Uninstall the MSI file with the given ID at the given Path](https://github.com/PowerShell/PSDscResources/blob/dev/Examples/Sample_MsiPackage_UninstallPackageFromFile.ps1) +* [Install the MSI file with the given ID at the given HTTP URL](https://github.com/PowerShell/PSDscResources/blob/dev/Examples/Sample_MsiPackage_InstallPackageFromHttp.ps1) +* [Uninstall the MSI file with the given ID at the given HTTPS URL](https://github.com/PowerShell/PSDscResources/blob/dev/Examples/Sample_MsiPackage_UnstallPackageFromHttps.ps1) + ### Registry Provides a mechanism to manage registry keys and values on a target node. @@ -538,6 +579,7 @@ The following parameters will be the same for each process in the set: * Archive: * Fixed a minor bug in the unit tests where sometimes the incorrect DateTime format was used. +* Added MsiPackage ### 2.5.0.0 diff --git a/Tests/Integration/MSFT_MsiPackage.EndToEnd.Tests.ps1 b/Tests/Integration/MSFT_MsiPackage.EndToEnd.Tests.ps1 new file mode 100644 index 0000000..93add37 --- /dev/null +++ b/Tests/Integration/MSFT_MsiPackage.EndToEnd.Tests.ps1 @@ -0,0 +1,643 @@ +<# + Please note that some of these tests depend on each other. + They must be run in the order given - if one test fails, subsequent tests may + also fail. +#> + +$errorActionPreference = 'Stop' +Set-StrictMode -Version 'Latest' + +if ($PSVersionTable.PSVersion.Major -lt 5 -or $PSVersionTable.PSVersion.Minor -lt 1) +{ + Write-Warning -Message 'Cannot run PSDscResources integration tests on PowerShell versions lower than 5.1' + return +} + +Describe 'MsiPackage End to End Tests' { + BeforeAll { + # Import CommonTestHelper + $testsFolderFilePath = Split-Path $PSScriptRoot -Parent + $testHelperFolderFilePath = Join-Path -Path $testsFolderFilePath -ChildPath 'TestHelpers' + $commonTestHelperFilePath = Join-Path -Path $testHelperFolderFilePath -ChildPath 'CommonTestHelper.psm1' + Import-Module -Name $commonTestHelperFilePath + + $script:testEnvironment = Enter-DscResourceTestEnvironment ` + -DscResourceModuleName 'PSDscResources' ` + -DscResourceName 'MSFT_MsiPackage' ` + -TestType 'Integration' + + # Import MsiPackage resource module for Test-TargetResource + $moduleRootFilePath = Split-Path -Path $testsFolderFilePath -Parent + $dscResourcesFolderFilePath = Join-Path -Path $moduleRootFilePath -ChildPath 'DscResources' + $msiPackageResourceFolderFilePath = Join-Path -Path $dscResourcesFolderFilePath -ChildPath 'MSFT_MsiPackage' + $msiPackageResourceModuleFilePath = Join-Path -Path $msiPackageResourceFolderFilePath -ChildPath 'MSFT_MsiPackage.psm1' + Import-Module -Name $msiPackageResourceModuleFilePath -Force + + # Import the MsiPackage test helper + $packageTestHelperFilePath = Join-Path -Path $testHelperFolderFilePath -ChildPath 'MSFT_MsiPackageResource.TestHelper.psm1' + Import-Module -Name $packageTestHelperFilePath -Force + + # Set up the paths to the test configurations + $script:configurationFilePathNoOptionalParameters = Join-Path -Path $PSScriptRoot -ChildPath 'MSFT_MsiPackage_NoOptionalParameters' + $script:configurationFilePathLogPath = Join-Path -Path $PSScriptRoot -ChildPath 'MSFT_MsiPackage_LogPath' + + <# + This log file is used to log messages from the mock server which is important for debugging since + most of the work of the mock server is done within a separate process. + #> + $script:logFile = Join-Path -Path $PSScriptRoot -ChildPath 'PackageTestLogFile.txt' + $script:environmentInIncorrectStateErrorMessage = 'The current environment is not in the expected state for this test - either something was setup incorrectly on your machine or a previous test failed - results after this may be invalid.' + + $script:msiName = 'DSCSetupProject.msi' + $script:msiLocation = Join-Path -Path $TestDrive -ChildPath $script:msiName + + $script:packageId = '{deadbeef-80c6-41e6-a1b9-8bdb8a05027f}' + + $null = New-TestMsi -DestinationPath $script:msiLocation + + # Clear the log file + 'Beginning integration tests' > $script:logFile + } + + AfterAll { + # Remove the test MSI if it is still installed + if (Test-PackageInstalledById -ProductId $script:packageId) + { + $null = Start-Process -FilePath 'msiexec.exe' -ArgumentList @("/x$script:packageId", '/passive') -Wait + $null = Start-Sleep -Seconds 1 + } + + if (Test-PackageInstalledById -ProductId $script:packageId) + { + throw 'Test package could not be uninstalled after running all tests. It may cause errors in subsequent test runs.' + } + + Exit-DscResourceTestEnvironment -TestEnvironment $script:testEnvironment + } + + Context 'Uninstall package that is already Absent' { + $configurationName = 'RemoveAbsentMsiPackage' + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $script:msiLocation + Ensure = 'Absent' + } + + It 'Should return True from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $true + + if ($testTargetResourceInitialResult -ne $true) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should not exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathNoOptionalParameters -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + + It 'Should return True from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Package should not exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + } + + Context 'Install package that is not installed yet' { + $configurationName = 'InstallMsiPackage' + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $script:msiLocation + Ensure = 'Present' + } + + It 'Should return False from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $false + + if ($testTargetResourceInitialResult -ne $false) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should not exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathNoOptionalParameters -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + + It 'Should return True from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Package should exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + } + + Context 'Install package that is already installed' { + $configurationName = 'InstallExistingMsiPackage' + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $script:msiLocation + Ensure = 'Present' + } + + It 'Should return True from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $true + + if ($testTargetResourceInitialResult -ne $true) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathNoOptionalParameters -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + + It 'Should return True from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Package should exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + } + + Context 'Uninstall package that is installed' { + $configurationName = 'UninstallExistingMsiPackage' + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $script:msiLocation + Ensure = 'Absent' + } + + It 'Should return False from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $false + + if ($testTargetResourceInitialResult -ne $false) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathNoOptionalParameters -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + + It 'Should return True from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Package should not exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + } + + Context 'Install package that is not installed and write to specified log file' { + $configurationName = 'InstallWithLogFile' + + $logPath = Join-Path -Path $TestDrive -ChildPath 'TestMsiLog.txt' + + if (Test-Path -Path $logPath) + { + Remove-Item -Path $logPath -Force + } + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $script:msiLocation + Ensure = 'Present' + LogPath = $logPath + } + + It 'Should return False from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $false + + if ($testTargetResourceInitialResult -ne $false) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should not exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathLogPath -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + + It 'Should return True from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Should have created the log file' { + Test-Path -Path $logPath | Should Be $true + } + + It 'Package should exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + } + + Context 'Uninstall package that is installed and write to specified log file' { + $configurationName = 'InstallWithLogFile' + + $logPath = Join-Path -Path $TestDrive -ChildPath 'TestMsiLog.txt' + + if (Test-Path -Path $logPath) + { + Remove-Item -Path $logPath -Force + } + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $script:msiLocation + Ensure = 'Absent' + LogPath = $logPath + } + + It 'Should return False from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $false + + if ($testTargetResourceInitialResult -ne $false) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathLogPath -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + + It 'Should return True from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Should have created the log file' { + Test-Path -Path $logPath | Should Be $true + } + + It 'Package should not exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + } + + Context 'Install package from HTTP Url' { + $configurationName = 'UninstallExistingMsiPackageFromHttp' + + $baseUrl = 'http://localhost:1242/' + $msiUrl = "$baseUrl" + 'package.msi' + + $fileServerStarted = $null + $job = $null + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $msiUrl + Ensure = 'Present' + } + + It 'Should return False from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $false + + if ($testTargetResourceInitialResult -ne $false) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should not exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + + try + { + $serverResult = Start-Server -FilePath $script:msiLocation -LogPath $script:logFile -Https $false + $fileServerStarted = $serverResult.FileServerStarted + $job = $serverResult.Job + + $fileServerStarted.WaitOne(30000) + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathNoOptionalParameters -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + } + finally + { + <# + This must be called after Start-Server to ensure the listening port is closed, + otherwise subsequent tests may fail until the machine is rebooted. + #> + Stop-Server -FileServerStarted $fileServerStarted -Job $job + } + + It 'Should return True from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Package should exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + } + + Context 'Uninstall Msi package from HTTP Url' { + $configurationName = 'InstallMsiPackageFromHttp' + + $baseUrl = 'http://localhost:1242/' + $msiUrl = "$baseUrl" + 'package.msi' + + $fileServerStarted = $null + $job = $null + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $msiUrl + Ensure = 'Absent' + } + + It 'Should return False from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $false + + if ($testTargetResourceInitialResult -ne $false) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + + try + { + $serverResult = Start-Server -FilePath $script:msiLocation -LogPath $script:logFile -Https $false + $fileServerStarted = $serverResult.FileServerStarted + $job = $serverResult.Job + + $fileServerStarted.WaitOne(30000) + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathNoOptionalParameters -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + } + finally + { + <# + This must be called after Start-Server to ensure the listening port is closed, + otherwise subsequent tests may fail until the machine is rebooted. + #> + Stop-Server -FileServerStarted $fileServerStarted -Job $job + } + + It 'Should return true from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Package should not exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + } + + Context 'Install Msi package from HTTPS Url' { + $configurationName = 'InstallMsiPackageFromHttpS' + + $baseUrl = 'https://localhost:1243/' + $msiUrl = "$baseUrl" + 'package.msi' + + $fileServerStarted = $null + $job = $null + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $msiUrl + Ensure = 'Present' + } + + It 'Should return False from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $false + + if ($testTargetResourceInitialResult -ne $false) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should not exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + + try + { + $serverResult = Start-Server -FilePath $script:msiLocation -LogPath $script:logFile -Https $true + $fileServerStarted = $serverResult.FileServerStarted + $job = $serverResult.Job + + $fileServerStarted.WaitOne(30000) + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathNoOptionalParameters -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + } + finally + { + <# + This must be called after Start-Server to ensure the listening port is closed, + otherwise subsequent tests may fail until the machine is rebooted. + #> + Stop-Server -FileServerStarted $fileServerStarted -Job $job + } + + It 'Should return true from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Package should exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + } + + Context 'Uninstall Msi package from HTTPS Url' { + $configurationName = 'UninstallMsiPackageFromHttps' + + $baseUrl = 'https://localhost:1243/' + $msiUrl = "$baseUrl" + 'package.msi' + + $fileServerStarted = $null + $job = $null + + $msiPackageParameters = @{ + ProductId = $script:packageId + Path = $msiUrl + Ensure = 'Absent' + } + + It 'Should return False from Test-TargetResource with the same parameters before configuration' { + $testTargetResourceInitialResult = MSFT_MsiPackage\Test-TargetResource @msiPackageParameters + $testTargetResourceInitialResult | Should Be $false + + if ($testTargetResourceInitialResult -ne $false) + { + <# + Not throwing an error here since the tests should still run correctly after this, + we just want to notify the user that the tests aren't necessarily testing what + they should be + #> + Write-Error -Message $script:environmentInIncorrectStateErrorMessage + } + } + + It 'Package should exist on the machine before configuration is run' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + } + + try + { + $serverResult = Start-Server -FilePath $script:msiLocation -LogPath $script:logFile -Https $true + $fileServerStarted = $serverResult.FileServerStarted + $job = $serverResult.Job + + $fileServerStarted.WaitOne(30000) + + It 'Should compile and run configuration' { + { + . $script:configurationFilePathNoOptionalParameters -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @msiPackageParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + } + } + finally + { + <# + This must be called after Start-Server to ensure the listening port is closed, + otherwise subsequent tests may fail until the machine is rebooted. + #> + Stop-Server -FileServerStarted $fileServerStarted -Job $job + } + + It 'Should return true from Test-TargetResource with the same parameters after configuration' { + MSFT_MsiPackage\Test-TargetResource @msiPackageParameters | Should Be $true + } + + It 'Package should not exist on the machine' { + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + } +} diff --git a/Tests/Integration/MSFT_MsiPackage.Integration.Tests.ps1 b/Tests/Integration/MSFT_MsiPackage.Integration.Tests.ps1 new file mode 100644 index 0000000..f45b4f5 --- /dev/null +++ b/Tests/Integration/MSFT_MsiPackage.Integration.Tests.ps1 @@ -0,0 +1,309 @@ +$errorActionPreference = 'Stop' +Set-StrictMode -Version 'Latest' + +if ($PSVersionTable.PSVersion.Major -lt 5 -or $PSVersionTable.PSVersion.Minor -lt 1) +{ + Write-Warning -Message 'Cannot run PSDscResources integration tests on PowerShell versions lower than 5.1' + return +} + +$script:testsFolderFilePath = Split-Path $PSScriptRoot -Parent +$testHelperFolderFilePath = Join-Path -Path $testsFolderFilePath -ChildPath 'TestHelpers' +$commonTestHelperFilePath = Join-Path -Path $testHelperFolderFilePath -ChildPath 'CommonTestHelper.psm1' +Import-Module -Name $script:commonTestHelperFilePath + +$script:testEnvironment = Enter-DscResourceTestEnvironment ` + -DscResourceModuleName 'PSDscResources' ` + -DscResourceName 'MSFT_MsiPackage' ` + -TestType 'Unit' + +try +{ + InModuleScope 'MSFT_MsiPackage' { + Describe 'MSFT_MsiPackage Integration Tests' { + BeforeAll { + $testsFolderFilePath = Split-Path $PSScriptRoot -Parent + $testHelperFolderFilePath = Join-Path -Path $testsFolderFilePath -ChildPath 'TestHelpers' + $commonTestHelperFilePath = Join-Path -Path $testHelperFolderFilePath -ChildPath 'CommonTestHelper.psm1' + $packageTestHelperFilePath = Join-Path -Path $testHelperFolderFilePath -ChildPath 'MSFT_MsiPackageResource.TestHelper.psm1' + $commonTestHelperFilePath = Join-Path -Path $testHelperFolderFilePath -ChildPath 'CommonTestHelper.psm1' + + Import-Module -Name $packageTestHelperFilePath -Force + + # The common test helper file needs to be imported twice because of the InModuleScope + Import-Module -Name $commonTestHelperFilePath + + $script:skipHttpsTest = $false + + <# + This log file is used to log messages from the mock server which is important for debugging since + most of the work of the mock server is done within a separate process. + #> + $script:logFile = Join-Path -Path $PSScriptRoot -ChildPath 'PackageTestLogFile.txt' + + $script:msiName = 'DSCSetupProject.msi' + $script:msiLocation = Join-Path -Path $TestDrive -ChildPath $script:msiName + $script:msiArguments = '/NoReboot' + + $script:packageId = '{deadbeef-80c6-41e6-a1b9-8bdb8a05027f}' + + $null = New-TestMsi -DestinationPath $script:msiLocation + + # Clear the log file + 'Beginning integration tests' > $script:logFile + } + + BeforeEach { + if (Test-PackageInstalledById -ProductId $script:packageId) + { + $null = Start-Process -FilePath 'msiexec.exe' -ArgumentList @("/x$script:packageId", '/passive') -Wait + $null = Start-Sleep -Seconds 1 + } + + if (Test-PackageInstalledById -ProductId $script:packageId) + { + throw 'Test package could not be uninstalled after running all tests. It may cause errors in subsequent test runs.' + } + } + + AfterAll { + if (Test-PackageInstalledById -ProductId $script:packageId) + { + $null = Start-Process -FilePath 'msiexec.exe' -ArgumentList @("/x$script:packageId", '/passive') -Wait + $null = Start-Sleep -Seconds 1 + } + + if (Test-PackageInstalledById -ProductId $script:packageId) + { + throw 'Test package could not be uninstalled after running test' + } + } + + Context 'Get-TargetResource' { + It 'Should return only basic properties for absent package' { + $packageParameters = @{ + Path = $script:msiLocation + ProductId = $script:packageId + } + + $getTargetResourceResult = Get-TargetResource @packageParameters + $getTargetResourceResultProperties = @( 'Ensure', 'ProductId' ) + + Test-GetTargetResourceResult -GetTargetResourceResult $getTargetResourceResult -GetTargetResourceResultProperties $getTargetResourceResultProperties + } + + It 'Should return full package properties for present package without registry check parameters specified' { + $packageParameters = @{ + Path = $script:msiLocation + ProductId = $script:packageId + } + + Set-TargetResource -Ensure 'Present' @packageParameters + + $getTargetResourceResult = Get-TargetResource @packageParameters + $getTargetResourceResultProperties = @( 'Ensure', 'Name', 'InstallSource', 'InstalledOn', 'ProductId', 'Size', 'Version', 'PackageDescription', 'Publisher' ) + + Test-GetTargetResourceResult -GetTargetResourceResult $getTargetResourceResult -GetTargetResourceResultProperties $getTargetResourceResultProperties + } + } + + Context 'Test-TargetResource' { + It 'Should return correct value when package is absent' { + $testTargetResourceResult = Test-TargetResource ` + -Ensure 'Present' ` + -Path $script:msiLocation ` + -ProductId $script:packageId + + $testTargetResourceResult | Should Be $false + + $testTargetResourceResult = Test-TargetResource ` + -Ensure 'Absent' ` + -Path $script:msiLocation ` + -ProductId $script:packageId + + $testTargetResourceResult | Should Be $true + } + + It 'Should return correct value when package is present' { + Set-TargetResource -Ensure 'Present' -Path $script:msiLocation -ProductId $script:packageId + + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + + $testTargetResourceResult = Test-TargetResource ` + -Ensure 'Present' ` + -Path $script:msiLocation ` + -ProductId $script:packageId ` + + $testTargetResourceResult | Should Be $true + + $testTargetResourceResult = Test-TargetResource ` + -Ensure 'Absent' ` + -Path $script:msiLocation ` + -ProductId $script:packageId ` + + $testTargetResourceResult | Should Be $false + } + } + + Context 'Set-TargetResource' { + It 'Should correctly install and remove a .msi package' { + Set-TargetResource -Ensure 'Present' -Path $script:msiLocation -ProductId $script:packageId + + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + + $getTargetResourceResult = Get-TargetResource -Path $script:msiLocation -ProductId $script:packageId + + $getTargetResourceResult.Version | Should Be '1.2.3.4' + $getTargetResourceResult.InstalledOn | Should Be ('{0:d}' -f [DateTime]::Now.Date) + $getTargetResourceResult.ProductId | Should Be $script:packageId + + [Math]::Round($getTargetResourceResult.Size, 2) | Should Be 0.03 + + Set-TargetResource -Ensure 'Absent' -Path $script:msiLocation -ProductId $script:packageId + + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + + It 'Should throw with incorrect product id' { + $wrongPackageId = '{deadbeef-80c6-41e6-a1b9-8bdb8a050272}' + + { Set-TargetResource -Ensure 'Present' -Path $script:msiLocation -ProductId $wrongPackageId } | Should Throw + } + + It 'Should correctly install and remove a package from a HTTP URL' { + $baseUrl = 'http://localhost:1242/' + $msiUrl = "$baseUrl" + 'package.msi' + + $fileServerStarted = $null + $job = $null + + try + { + 'Http tests:' >> $script:logFile + + $serverResult = Start-Server -FilePath $script:msiLocation -LogPath $script:logFile -Https $false + $fileServerStarted = $serverResult.FileServerStarted + $job = $serverResult.Job + + # Wait for the file server to be ready to receive requests + $fileServerStarted.WaitOne(30000) + + { Set-TargetResource -Ensure 'Present' -Path $baseUrl -ProductId $script:packageId } | Should Throw + + Set-TargetResource -Ensure 'Present' -Path $msiUrl -ProductId $script:packageId + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + + Set-TargetResource -Ensure 'Absent' -Path $msiUrl -ProductId $script:packageId + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + finally + { + <# + This must be called after Start-Server to ensure the listening port is closed, + otherwise subsequent tests may fail until the machine is rebooted. + #> + Stop-Server -FileServerStarted $fileServerStarted -Job $job + } + } + + It 'Should correctly install and remove a package from a HTTPS URL' -Skip:$script:skipHttpsTest { + + $baseUrl = 'https://localhost:1243/' + $msiUrl = "$baseUrl" + 'package.msi' + + $fileServerStarted = $null + $job = $null + + try + { + 'Https tests:' >> $script:logFile + + $serverResult = Start-Server -FilePath $script:msiLocation -LogPath $script:logFile -Https $true + $fileServerStarted = $serverResult.FileServerStarted + $job = $serverResult.Job + + # Wait for the file server to be ready to receive requests + $fileServerStarted.WaitOne(30000) + + { Set-TargetResource -Ensure 'Present' -Path $baseUrl -ProductId $script:packageId } | Should Throw + + Set-TargetResource -Ensure 'Present' -Path $msiUrl -ProductId $script:packageId + Test-PackageInstalledById -ProductId $script:packageId | Should Be $true + + Set-TargetResource -Ensure 'Absent' -Path $msiUrl -ProductId $script:packageId + Test-PackageInstalledById -ProductId $script:packageId | Should Be $false + } + finally + { + <# + This must be called after Start-Server to ensure the listening port is closed, + otherwise subsequent tests may fail until the machine is rebooted. + #> + Stop-Server -FileServerStarted $fileServerStarted -Job $job + } + } + + It 'Should write to the specified log path' { + $logPath = Join-Path -Path $TestDrive -ChildPath 'TestMsiLog.txt' + + if (Test-Path -Path $logPath) + { + Remove-Item -Path $logPath -Force + } + + Set-TargetResource -Ensure 'Present' -Path $script:msiLocation -LogPath $logPath -ProductId $script:packageId + + Test-Path -Path $logPath | Should Be $true + Get-Content -Path $logPath | Should Not Be $null + } + + It 'Should add space after .MSI installation arguments' { + Mock Invoke-Process -ParameterFilter { $Process.StartInfo.Arguments.EndsWith($script:msiArguments) } { return @{ ExitCode = 0 } } + Mock Get-ProductEntry { return $script:packageId } + + $packageParameters = @{ + Path = $script:msiLocation + ProductId = $script:packageId + Arguments = $script:msiArguments + } + + Set-TargetResource -Ensure 'Present' @packageParameters + + Assert-MockCalled Invoke-Process -ParameterFilter { $Process.StartInfo.Arguments.EndsWith(" $script:msiArguments") } -Scope It + } + + It 'Should not check for product installation when rebooted is required' { + Mock Invoke-Process { return 3010 } + Mock Get-ProductEntry { } + + $packageParameters = @{ + Path = $script:msiLocation + ProductId = $script:packageId + } + + { Set-TargetResource -Ensure 'Present' @packageParameters } | Should Not Throw + } + + It 'Should install package using user credentials when specified' { + Mock Invoke-PInvoke { } + Mock Get-ProductEntry { return $script:packageId } + + $packageCredential = [System.Management.Automation.PSCredential]::Empty + $packageParameters = @{ + Path = $script:msiLocation + ProductId = $script:packageId + RunAsCredential = $packageCredential + } + + Set-TargetResource -Ensure 'Present' @packageParameters + + Assert-MockCalled Invoke-PInvoke -ParameterFilter { $RunAsCredential -eq $packageCredential} -Scope It + } + } + } + } +} +finally +{ + Exit-DscResourceTestEnvironment -TestEnvironment $script:testEnvironment +} diff --git a/Tests/Integration/MSFT_MsiPackage_LogPath.ps1 b/Tests/Integration/MSFT_MsiPackage_LogPath.ps1 new file mode 100644 index 0000000..ee5dafe --- /dev/null +++ b/Tests/Integration/MSFT_MsiPackage_LogPath.ps1 @@ -0,0 +1,43 @@ +param +( + [Parameter(Mandatory = $true)] + [String] + $ConfigurationName +) + +Configuration $ConfigurationName +{ + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $ProductId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Path, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [Parameter(Mandatory = $true)] + [String] + $LogPath + ) + + Import-DscResource -ModuleName 'PSDscResources' + + Node localhost + { + MsiPackage MsiPackage1 + { + ProductId = $ProductId + Path = $Path + Ensure = $Ensure + LogPath = $LogPath + } + } +} diff --git a/Tests/Integration/MSFT_MsiPackage_NoOptionalParameters.ps1 b/Tests/Integration/MSFT_MsiPackage_NoOptionalParameters.ps1 new file mode 100644 index 0000000..209cd28 --- /dev/null +++ b/Tests/Integration/MSFT_MsiPackage_NoOptionalParameters.ps1 @@ -0,0 +1,38 @@ +param +( + [Parameter(Mandatory = $true)] + [String] + $ConfigurationName +) + +Configuration $ConfigurationName +{ + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $ProductId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Path, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present' + ) + + Import-DscResource -ModuleName 'PSDscResources' + + Node localhost + { + MsiPackage MsiPackage1 + { + ProductId = $ProductId + Path = $Path + Ensure = $Ensure + } + } +} diff --git a/Tests/TestHelpers/CommonTestHelper.psm1 b/Tests/TestHelpers/CommonTestHelper.psm1 index 7cf6d24..c5671e9 100644 --- a/Tests/TestHelpers/CommonTestHelper.psm1 +++ b/Tests/TestHelpers/CommonTestHelper.psm1 @@ -10,6 +10,386 @@ Set-StrictMode -Version 'Latest' #> $script:appVeyorAdministratorCredential = $null +<# + String data for unit test names to be used with the generic test functions. + Maps command names to the appropriate test names to insert when checking that + the correct mocks are called. + In the future we will move this data out of the commonTestHelper file and into the + corresponding test file or its own file. For now, it is much easier to access this + way rather than passing it around. +#> +data testStrings +{ + ConvertFrom-StringData -StringData @' +Assert-FileHashValid = assert that the file hash is valid +Assert-FileSignatureValid = assert that the file signature is valid +Assert-FileValid = assert that the file is valid +Assert-PathExtensionValid = assert that the specified path extension is valid +Close-Stream = close the stream +Convert-PathToUri = convert the path to a URI +Convert-ProductIdToIdentifyingNumber = convert the product ID to the identifying number +Copy-ResponseStreamToFileStream = copy the response to the outstream +Get-ItemProperty = retrieve {0} +Get-MsiProductCode = retrieve the MSI product code +Get-ProductEntry = retrieve the product entry +Get-ProductEntryInfo = retrieve the product entry info +Get-ProductEntryValue = retrieve the value of the product entry property +Get-ScriptBlock = retrieve the script block +Get-WebRequest = retrieve the WebRequest object +Get-WebRequestResponse = retrieve the web request response +Get-WebRequestResponseStream = retrieve the WebRequest response stream +Invoke-CimMethod = attempt to invoke a cim method to check if reboot is required +Invoke-PInvoke = attempt to {0} the MSI package under the user +Invoke-Process = attempt to {0} the MSI package under the process +New-Item = create a new {0} +New-LogFile = create a new log file +New-Object = create a new {0} +New-PSDrive = create a new PS Drive +Remove-Item = remove {0} +Remove-PSDrive = remove the PS drive +Start-MsiProcess = start the MSI process +Test-Path = test that the path {0} exists +'@ +} + +<# + .SYNOPSIS + Retrieves the name of the test for asserting that the given function is called. + + .PARAMETER IsCalled + Indicates whether the function should be called or not. + + .PARAMETER Custom + An optional string to include in the test name to make the name more descriptive. + Can only be used by commands that have a variable in their string data name. +#> +function Get-TestName +{ + [OutputType([String])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $Command, + + [Boolean] + $IsCalled = $true, + + [String] + $Custom = '' + ) + + $testName = '' + + if (-not [String]::IsNullOrEmpty($Custom)) + { + $testName = ($testStrings.$Command -f $Custom) + } + else + { + $testName = $testStrings.$Command + } + + if ($IsCalled) + { + return 'Should ' + $testName + } + else + { + return 'Should not ' + $testName + } +} + +<# + .SYNOPSIS + Tests that each mock in MocksCalled is called the expected number of times. + + .PARAMETER MocksCalled + An array of the mocked commands that should be called or not called. + Each item in the array is a hashtable that contains the name of the command + being mocked and the number of times it is called (can be 0). +#> +function Invoke-ExpectedMocksAreCalledTest +{ + [CmdletBinding()] + param + ( + [Hashtable[]] + $MocksCalled + ) + + foreach ($mock in $MocksCalled) + { + $testName = Get-TestName -Command $mock.Command -IsCalled $mock.Times + + if ($mock.Keys -contains 'Custom') + { + $testName = Get-TestName -Command $mock.Command -IsCalled $mock.Times -Custom $mock.Custom + } + + It $testName { + Assert-MockCalled -CommandName $mock.Command -Exactly $mock.Times -Scope 'Context' + } + } +} + +<# + .SYNOPSIS + Performs generic tests for the given function, including checking that the + function does not throw and checking that all mocks are called the expected + number of times. + + .PARAMETER Function + The function to be called. Must be in format: + { Param($hashTableOfParamsToPass) Function-Name @hashTableOfParamsToPass } + For example: + { Param($testLogPath) New-LogFile $script:testPath } + or + { Param($startMsiProcessParameters) Start-MsiProcess @startMsiProcessParameters } + + .PARAMETER FunctionParameters + The parameters that should be passed to the function for this test. Should match + what is passed in the Function parameter. + + .PARAMETER MocksCalled + An array of the mocked commands that should be called for this test. + Each item in the array is a hashtable that contains the name of the command + being mocked, the number of times it is called (can be 0) and, optionally, + an extra custom string to make the test name more descriptive. The custom + string will only work if the command has a corresponding variable in the + string data name. + + .PARAMETER ShouldThrow + Indicates whether the function should throw or not. If this is set to True + then ErrorMessage and ErrorTestName should also be passed. + + .PARAMETER ErrorMessage + The error message that should be thrown if the function is supposed to throw. + + .PARAMETER ErrorTestName + The string that should be used to create the name of the test that checks for + the correct error being thrown. +#> +function Invoke-GenericUnitTest { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ScriptBlock] + $Function, + + [Parameter(Mandatory = $true)] + [Hashtable] + $FunctionParameters, + + [Hashtable[]] + $MocksCalled, + + [Boolean] + $ShouldThrow = $false, + + [String] + $ErrorMessage = '', + + [String] + $ErrorTestName = '' + ) + + if ($ShouldThrow) + { + It "Should throw an error for $ErrorTestName" { + { $null = $($Function.Invoke($FunctionParameters)) } | Should Throw $ErrorMessage + } + } + else + { + It 'Should not throw' { + { $null = $($Function.Invoke($FunctionParameters)) } | Should Not Throw + } + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $MocksCalled +} + +<# + .SYNOPSIS + Performs generic tests for Get-TargetResource, including checking that the + function does not throw, checking that all mocks are called the expected + number of times, and checking that the correct result is returned. If the function + is expected to throw, then this function should not be used. + + .PARAMETER GetTargetResourceParameters + The parameters that should be passed to Get-TargetResource for this test. + + .PARAMETER MocksCalled + An array of the mocked commands that should be called for this test. + Each item in the array is a hashtable that contains the name of the command + being mocked, the number of times it is called (can be 0) and, optionally, + an extra custom string to make the test name more descriptive. The custom + string will only work if the command has a corresponding variable in the + string data name. + + .PARAMETER ExpectedReturnValue + The expected hashtable that Get-TargetResource should return for this test. +#> +function Invoke-GetTargetResourceUnitTest +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [Hashtable] + $GetTargetResourceParameters, + + [Hashtable[]] + $MocksCalled, + + [Parameter(Mandatory = $true)] + [Hashtable] + $ExpectedReturnValue + ) + + It 'Should not throw' { + { $null = Get-TargetResource @GetTargetResourceParameters } | Should Not Throw + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $MocksCalled + + $getTargetResourceResult = Get-TargetResource @GetTargetResourceParameters + + It 'Should return a Hashtable' { + $getTargetResourceResult -is [Hashtable] | Should Be $true + } + + It "Should return a Hashtable with $($ExpectedReturnValue.Keys.Count) properties" { + $getTargetResourceResult.Keys.Count | Should Be $ExpectedReturnValue.Keys.Count + } + + foreach ($key in $ExpectedReturnValue.Keys) + { + It "Should return a Hashtable with the $key property as $($ExpectedReturnValue.$key)" { + $getTargetResourceResult.$key | Should Be $ExpectedReturnValue.$key + } + } +} + +<# + .SYNOPSIS + Performs generic tests for Set-TargetResource, including checking that the + function does not throw and checking that all mocks are called the expected + number of times. + + .PARAMETER SetTargetResourceParameters + The parameters that should be passed to Set-TargetResource for this test. + + .PARAMETER MocksCalled + An array of the mocked commands that should be called for this test. + Each item in the array is a hashtable that contains the name of the command + being mocked, the number of times it is called (can be 0) and, optionally, + an extra custom string to make the test name more descriptive. The custom + string will only work if the command has a corresponding variable in the + string data name. + + .PARAMETER ShouldThrow + Indicates whether Set-TargetResource should throw or not. If this is set to True + then ErrorMessage and ErrorTestName should also be passed. + + .PARAMETER ErrorMessage + The error message that should be thrown if Set-TargetResource is supposed to throw. + + .PARAMETER ErrorTestName + The string that should be used to create the name of the test that checks for + the correct error being thrown. +#> +function Invoke-SetTargetResourceUnitTest { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [Hashtable] + $SetTargetResourceParameters, + + [Hashtable[]] + $MocksCalled, + + [Boolean] + $ShouldThrow = $false, + + [String] + $ErrorMessage = '', + + [String] + $ErrorTestName = '' + ) + + if ($ShouldThrow) + { + It "Should throw an error for $ErrorTestName" { + { $null = Set-TargetResource @SetTargetResourceParameters } | Should Throw $ErrorMessage + } + } + else + { + It 'Should not throw' { + { $null = Set-TargetResource @SetTargetResourceParameters } | Should Not Throw + } + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $MocksCalled +} + +<# + .SYNOPSIS + Performs generic tests for Test-TargetResource, including checking that the + function does not throw, checking that all mocks are called the expected + number of times, and checking that the correct result is returned. If the function + is expected to throw, then this function should not be used. + + .PARAMETER TestTargetResourceParameters + The parameters that should be passed to Test-TargetResource for this test. + + .PARAMETER MocksCalled + An array of the mocked commands that should be called for this test. + Each item in the array is a hashtable that contains the name of the command + being mocked, the number of times it is called (can be 0) and, optionally, + an extra custom string to make the test name more descriptive. The custom + string will only work if the command has a corresponding variable in the + string data name. + + .PARAMETER ExpectedReturnValue + The expected boolean value that should be returned +#> +function Invoke-TestTargetResourceUnitTest +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [Hashtable] + $TestTargetResourceParameters, + + [Hashtable[]] + $MocksCalled, + + [Parameter(Mandatory = $true)] + [Boolean] + $ExpectedReturnValue + ) + + It 'Should not throw' { + { $null = Test-TargetResource @TestTargetResourceParameters } | Should Not Throw + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $MocksCalled + + $testTargetResourceResult = Test-TargetResource @TestTargetResourceParameters + + It "Should return $ExpectedReturnValue" { + $testTargetResourceResult | Should Be $ExpectedReturnValue + } +} + <# .SYNOPSIS Tests that the Get-TargetResource method of a DSC Resource is not null, can be converted to a hashtable, and has the correct properties. @@ -417,5 +797,10 @@ Export-ModuleMember -Function @( 'Test-SetTargetResourceWithWhatIf', ` 'Get-AppVeyorAdministratorCredential', ` 'Enter-DscResourceTestEnvironment', ` - 'Exit-DscResourceTestEnvironment' + 'Exit-DscResourceTestEnvironment', ` + 'Invoke-GetTargetResourceUnitTest', ` + 'Invoke-SetTargetResourceUnitTest', ` + 'Invoke-TestTargetResourceUnitTest', ` + 'Invoke-ExpectedMocksAreCalledTest', ` + 'Invoke-GenericUnitTest' ) diff --git a/Tests/TestHelpers/MSFT_MsiPackageResource.TestHelper.psm1 b/Tests/TestHelpers/MSFT_MsiPackageResource.TestHelper.psm1 new file mode 100644 index 0000000..b96befe --- /dev/null +++ b/Tests/TestHelpers/MSFT_MsiPackageResource.TestHelper.psm1 @@ -0,0 +1,1064 @@ +$errorActionPreference = 'Stop' +Set-StrictMode -Version 'Latest' + +<# + .SYNOPSIS + Tests if the package with the given Id is installed. + + .PARAMETER ProductId + The ID of the package to test for. +#> +function Test-PackageInstalledById +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [String] + $ProductId + ) + + $uninstallRegistryKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $uninstallRegistryKeyWow64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + + $productEntry = $null + + if (-not [String]::IsNullOrEmpty($ProductId)) + { + $productEntryKeyLocation = Join-Path -Path $uninstallRegistryKey -ChildPath $ProductId + $productEntry = Get-Item -Path $productEntryKeyLocation -ErrorAction 'SilentlyContinue' + + if ($null -eq $productEntry) + { + $productEntryKeyLocation = Join-Path -Path $uninstallRegistryKeyWow64 -ChildPath $ProductId + $productEntry = Get-Item $productEntryKeyLocation -ErrorAction 'SilentlyContinue' + } + } + + return ($null -ne $productEntry) +} + +<# + .SYNOPSIS + Starts a simple mock http or https file server. Server will stay on and continue to be able + to receive requests until the client calls Stop-Server. The server returns the job object + and an EventWaitHandle object that the client will need to dispose of (by calling Stop-Server) + once it is done sending requests. + + .PARAMETER FilePath + The path to the file to add on to the mock file server. Should be an MSI file. + + .PARAMETER LogPath + The path to the log file to write output to. This is important for debugging since + most of the work of this function is done within a separate process. Default value + will be in PSScriptRoot. + + .PARAMETER Https + Indicates whether the server should use Https. If True then the file server will use Https + and listen on port 'https://localhost:1243'. Otherwise the file server will use Http and + listen on port 'http://localhost:1242' + Default value is False (Http). +#> +function Start-Server +{ + [OutputType([Hashtable])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $FilePath, + + [String] + $LogPath = (Join-Path -Path $PSScriptRoot -ChildPath 'PackageTestLogFile.txt'), + + [System.Boolean] + $Https = $false + ) + + # Create an event object to let the client know when the server is ready to begin receiving requests. + $fileServerStarted = New-Object -TypeName 'System.Threading.EventWaitHandle' -ArgumentList @($false, [System.Threading.EventResetMode]::ManualReset, + 'HttpIntegrationTest.FileServerStarted') + $null = $fileServerStarted.Reset() + + <# + The server is run on a separate process so that it can receive requests + while the tests continue to run. It takes in the same parameterss that are passed + in to this function. All helper functions that the server uses have to be + defined within the scope of this script. + #> + $server = + { + param($FilePath, $LogPath, $Https) + + <# + .SYNOPSIS + Stops the listener, removes the SSL binding if applicable, and closes the listener. + + .PARAMETER HttpListener + The listner to stop and close. + + .PARAMETER Https + Indicates whether https was used and if so, removes the SSL binding. + #> + function Stop-Listener + { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.Net.HttpListener] + $HttpListener, + + [Parameter(Mandatory = $true)] + [System.Boolean] + $Https + ) + + Write-Log -LogFile $LogPath -Message 'Finished listening for requests. Shutting down HTTP server.' + + $ipPort = '0.0.0.0:1243' + + if ($null -eq $HttpListener) + { + $errorMessage = 'HttpListener was null when trying to close' + Write-Log -LogFile $LogPath -Message $errorMessage + + if ($Https) + { + Invoke-ConsoleCommand -Target $ipPort -Action 'removing SSL certificate binding' -ScriptBlock { + netsh http delete sslcert ipPort="$ipPort" + } + } + + throw $errorMessage + } + + if ($HttpListener.IsListening) + { + Write-Log -LogFile $LogPath -Message 'HttpListener is about to be stopped' + $HttpListener.Stop() + } + + if ($Https) + { + Write-Log -LogFile $LogPath -Message 'Removing SSL binding' + # Remove SSL Binding + Invoke-ConsoleCommand -Target $ipPort -Action 'removing SSL certificate binding' -ScriptBlock { + netsh http delete sslcert ipPort="$ipPort" + } + } + + Write-Log -LogFile $LogPath -Message 'Closing listener' + $HttpListener.Close() + + $null = netsh advfirewall set allprofiles state on + } + + <# + .SYNOPSIS + Creates and registers an SSL certificate for Https connections. + #> + function Register-Ssl + { + [CmdletBinding()] + param() + + # Create certificate + $certificate = New-SelfSignedCertificate -CertStoreLocation 'Cert:\LocalMachine\My' -DnsName localhost + Write-Log -LogFile $LogPath -Message 'Created certificate' + + $hash = $certificate.Thumbprint + $certPassword = ConvertTo-SecureString -String 'password12345' -AsPlainText -Force + $tempPath = 'C:\certForTesting' + + $null = Export-PfxCertificate -Cert $certificate -FilePath $tempPath -Password $certPassword + $null = Import-PfxCertificate -CertStoreLocation 'Cert:\LocalMachine\Root' -FilePath 'C:\certForTesting' -Password $certPassword + Remove-Item -Path $tempPath + + Write-Log -LogFile $LogPath -Message 'Finished importing certificate into root. About to bind it to port.' + + # Use net shell command to directly bind certificate to designated testing port + $null = netsh http add sslcert ipport=0.0.0.0:1243 certhash=$hash appid='{833f13c2-319a-4799-9d1a-5b267a0c3593}' clientcertnegotiation=enable + } + + <# + .SYNOPSIS + Defines the callback function required for BeginGetContext. + + .PARAMETER Callback + The callback script - in this case the requestListener script defined below. + #> + function New-ScriptBlockCallback + { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [ScriptBlock] + $Callback + ) + + # Add the CallbackEventBridge type if it's not already defined + if (-not ('CallbackEventBridge' -as [Type])) + { + Add-Type @' + using System; + + public sealed class CallbackEventBridge { + public event AsyncCallback CallbackComplete = delegate { }; + + private CallbackEventBridge() {} + + private void CallbackInternal(IAsyncResult result) + { + CallbackComplete(result); + } + + public AsyncCallback Callback + { + get { return new AsyncCallback(CallbackInternal); } + } + + public static CallbackEventBridge Create() + { + return new CallbackEventBridge(); + } + } +'@ + } + + $bridge = [CallbackEventBridge]::Create() + Register-ObjectEvent -InputObject $bridge -EventName 'CallbackComplete' -Action $Callback -MessageData $args > $null + $bridge.Callback + + Write-Log -LogFile $LogPath -Message 'Finished callback function' + } + + <# + .SYNOPSIS + Invokes a console command and captures the exit code. + + .PARAMETER Target + Where the command is being executed. + + .PARAMETER Action + A description of the action being performed. + + .PARAMETER ScriptBlock + The code to execute. + + #> + function Invoke-ConsoleCommand + { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $Target, + + [Parameter(Mandatory = $true)] + [String] + $Action, + + [Parameter(Mandatory = $true)] + [ScriptBlock] + $ScriptBlock + ) + + $output = Invoke-Command -ScriptBlock $ScriptBlock + + if ($LASTEXITCODE) + { + $output = $output -join [Environment]::NewLine + $message = ('Failed action ''{0}'' on target ''{1}'' (exit code {2}): {3}' -f $Action,$Target,$LASTEXITCODE,$output) + Write-Error -Message $message + Write-Log -LogFile $LogPath -Message "Error from Invoke-ConsoleCommand: $message" + } + else + { + $nonNullOutput = $output | Where-Object { $_ -ne $null } + Write-Log -LogFile $LogPath -Message "Output from Invoke-ConsoleCommand: $nonNullOutput" + } + } + + <# + .SYNOPSIS + Writes the specified message to the specified log file. + Does NOT overwrite what is already written there. + + .PARAMETER LogFile + The path to the file to write to. + + .PARAMETER Message + The message to write to the file. + #> + function Write-Log + { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $LogFile, + + [Parameter(Mandatory = $true)] + [String] + $Message + ) + + $Message >> $LogFile + } + + # End of function declarations - Beginning of function execution + + if ($null -eq (Get-NetFirewallRule -DisplayName 'UnitTestRule' -ErrorAction 'SilentlyContinue')) + { + $null = New-NetFirewallRule -DisplayName 'UnitTestRule' -Direction 'Inbound' -Program "$PSHome\powershell.exe" -Authentication 'NotRequired' -Action 'Allow' + } + + $null = netsh advfirewall set allprofiles state off + + Write-Log -LogFile $LogPath -Message (Get-Date) + + $HttpListener = New-Object 'System.Net.HttpListener' + $fileServerStarted = $null + + try + { + # Set up the listener + if ($Https) + { + $HttpListener.Prefixes.Add([Uri]'https://localhost:1243') + + try + { + Register-SSL + } + catch + { + $errorMessage = "Unable to bind SSL certificate to port. Error: $_" + Write-Log -LogFile $LogPath -Message $errorMessage + throw $errorMessage + } + + Write-Log -LogFile $LogPath -Message 'Certificate is registered' + } + else + { + $HttpListener.Prefixes.Add([Uri]'http://localhost:1242') + } + + Write-Log -LogFile $LogPath -Message 'Finished listener setup - about to start listener' + + $HttpListener.Start() + + # Cue the tests that the listener is started and can begin receiving requests + $fileServerStarted = New-Object -TypeName 'System.Threading.EventWaitHandle' ` + -ArgumentList @($false, + [System.Threading.EventResetMode]::AutoReset, + 'HttpIntegrationTest.FileServerStarted' + ) + $fileServerStarted.Set() + + Write-Log -LogFile $LogPath -Message 'Listener is started' + + <# + .SYNOPSIS + Script block called by the callback function for BeginGetContext. + Ends the current BeginGetContext, copies the response, and calls BeginGetContext again + to continue receiving requests. + + .PARAMETER Result + th IAsyncResult containing the listener object and path to the MSI file. + + #> + $requestListener = + { + [CmdletBinding()] + param + ( + [IAsyncResult] + $Result + ) + + Write-Log -LogFile $LogPath -Message 'Starting request listener' + + $asyncState = $Result.AsyncState + [System.Net.HttpListener]$listener = $asyncState.Listener + $filepath = $asyncState.FilePath + + Write-Log -LogFile $LogPath -Message (ConvertTo-Json $asyncState) + + # Call EndGetContext to complete the asynchronous operation. + $context = $listener.EndGetContext($Result) + + $response = $null + + try + { + # Prepare binary buffer for http/https response + $fileInfo = New-Object -TypeName 'System.IO.FileInfo' -ArgumentList @( $filePath ) + $numBytes = $fileInfo.Length + $fileStream = New-Object -TypeName 'System.IO.FileStream' -ArgumentList @( $filePath, 'Open' ) + $binaryReader = New-Object -TypeName 'System.IO.BinaryReader' -ArgumentList @( $fileStream ) + [Byte[]] $buf = $binaryReader.ReadBytes($numBytes) + $fileStream.Close() + + Write-Log -LogFile $LogPath -Message 'Buffer prepared for response' + + $response = $context.Response + $response.ContentType = 'application/octet-stream' + $response.ContentLength64 = $buf.Length + $response.OutputStream.Write($buf, 0, $buf.Length) + + Write-Log -LogFile $LogPath -Message 'Response written' + + $response.OutputStream.Flush() + + # Open the response stream again to receive more requests + $listener.BeginGetContext((New-ScriptBlockCallback -Callback $requestListener), $asyncState) + } + catch + { + $errorMessage = "error writing response: $_" + Write-Log -LogFile $LogPath -Message $errorMessage + throw $errorMessage + } + finally + { + if ($null -ne $response) + { + $response.Dispose() + } + } + } + + # Register the request listener scriptblock as the async callback + $HttpListener.BeginGetContext((New-ScriptBlockCallback -Callback $requestListener), @{ Listener = $Httplistener; FilePath = $FilePath }) | Out-Null + Write-Log -LogFile $LogPath -Message 'First BeginGetContext called' + + # Ensure that the request listener stays on until the server is done receiving responses - client is responsible for stopping the server. + while ($true) + { + Start-Sleep -Milliseconds 100 + } + } + catch + { + $errorMessage = "There were problems setting up the HTTP(s) listener. Error: $_" + Write-Log -LogFile $LogPath -Message $errorMessage + throw $errorMessage + } + finally + { + if ($fileServerStarted) + { + $fileServerStarted.Dispose() + } + + Write-Log -LogFile $LogPath -Message 'Stopping the Server' + Stop-Listener -HttpListener $HttpListener -Https $Https + } + } + + $job = Start-Job -ScriptBlock $server -ArgumentList @( $FilePath, $LogPath, $Https ) + + <# + Return the event object so that client knows when it can start sending requests and + the job object so that the client can stop the job once it is done sending requests. + #> + return @{ + FileServerStarted = $fileServerStarted + Job = $job + } +} + +<# + .SYNOPSIS + Disposes the EventWaitHandle object and stops and removes the job to ensure that proper + cleanup is done for the listener. If this function is not called after Start-Server then + the listening port will remain open until the job is stopped or the machine is rebooted. + + .PARAMETER FileServerStarted + The EventWaitHandle object returned by Start-Server to let the client know that it is ready + to receive requests. The client is responsible for calling this function to ensure that + this object is disposed of once the client is done sending requests. + + .PARAMETER Job + The job object returned by Start-Server that needs to be stopped so that the server will + close the listening port. +#> +function Stop-Server +{ + [CmdletBinding()] + param + ( + [System.Threading.EventWaitHandle] + $FileServerStarted, + + [System.Management.Automation.Job] + $Job + ) + + if ($null -ne $FileServerStarted) + { + $FileServerStarted.Dispose() + } + + if ($null -ne $Job) + { + Stop-Job -Job $Job + Remove-Job -Job $Job + } +} + +<# + .SYNOPSIS + Creates a new MSI package for testing. + + .PARAMETER DestinationPath + The path at which to create the test msi file. +#> +function New-TestMsi +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $DestinationPath + ) + + #region msiContentInBase64 + $msiContentInBase64 = '0M8R4KGxGuEAAAAAAAAAAAAAAAAAAAAAPgAEAP7/DAAGAAAAAAAAAAEAAAABAAAAAQA' + ` + 'AAAAAAAAAEAAAAgAAAAEAAAD+////AAAAAAAAAAD/////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP3////+/////v///wYAAAD+////BAAAAP7////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '////////////////////////////////////////////////////////////9SAG8AbwB0ACAARQBuAHQAcgB' + ` + '5AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFgAFAP//////////CQAAAIQQ' + ` + 'DAAAAAAAwAAAAAAAAEYAAAAAAAAAAAAAAADwRqG1qh/OAQMAAAAAEwAAAAAAAAUAUwB1AG0AbQBhAHIAeQBJA' + ` + 'G4AZgBvAHIAbQBhAHQAaQBvAG4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAIA////////////////AA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwCAAAAAAAAQEj/P+RD7EHkRaxEMUgAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAgETAAAABAAAAP////8A' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJAAAAOAcAAAAAAABASMpBMEOxOztCJkY3QhxCN' + ` + 'EZoRCZCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAACAQsAAAAKAAAA/////w' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACYAAAAwAAAAAAAAAEBIykEwQ7E/Ej8oRThCsUE' + ` + 'oSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAIBDAAAAP//////////' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJwAAABgAAAAAAAAAQEjKQflFzkaoQfhFKD8oR' + ` + 'ThCsUEoSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAgD///////////////' + ` + '8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAAKgAAAAAAAABASIxE8ERyRGhEN0gAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgACAP//////////////' + ` + '/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACkAAAAMAAAAAAAAAEBIDUM1QuZFckU8SAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAIADgAAAAIAAAD///' + ` + '//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKgAAABIAAAAAAAAAQEgPQuRFeEUoSAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAgD/////////////' + ` + '//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArAAAAEAAAAAAAAABASA9C5EV4RSg7MkSzR' + ` + 'DFC8UU2SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFgACAQcAAAADAAAA//' + ` + '///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAEAAAAAAAAAEBIUkT2ReRDrzs7QiZ' + ` + 'GN0IcQjRGaEQmQgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaAAIBBQAAAAEAAAD/' + ` + '////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALQAAAHIAAAAAAAAAQEhSRPZF5EOvPxI/K' + ` + 'EU4QrFBKEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABYAAgH///////////' + ` + '////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAvAAAAMAAAAAAAAABASBVBeETmQoxE8UH' + ` + 'sRaxEMUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAACAP//////////' + ` + '/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAEAAAAAAAAAEBIWUXyRGhFN0cAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAIBDwAAAP////' + ` + '//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMQAAACQAAAAAAAAAQEgbQipD9kU1RwA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAgEQAAAADQAA' + ` + 'AP////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyAAAADAAAAAAAAABASN5EakXkQShIA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAACAP////////' + ` + '///////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMAAAAgAAAAAAAAAEBIfz9kQS9CNkg' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAIBEQAAAAgA' + ` + 'AAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANAAAACAAAAAAAAAAQEg/O/JDOESxR' + ` + 'QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAgD///////' + ` + '////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1AAAAWAIAAAAAAABASD8/d0VsRGo' + ` + '+skQvSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAACAP//////' + ` + '/////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAYAwAAAAAAAEBIPz93RWxEa' + ` + 'jvkRSRIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAIBBgAAAB' + ` + 'IAAAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAFAaAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////' + ` + '//////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////' + ` + '///////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////' + ` + '////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///' + ` + '////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//' + ` + '/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//' + ` + '//////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/' + ` + '//////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP' + ` + '///////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + '////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'D///////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AP///////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AA////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQA' + ` + 'AAAIAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAD+////CgAAAAsAAAAMAAAADQAAAA4AAAAPAAAAEAAAABEA' + ` + 'AAASAAAAEwAAABQAAAAVAAAAFgAAABcAAAAYAAAAGQAAABoAAAAbAAAAHAAAAB0AAAAeAAAAHwAAACAAAAAhA' + ` + 'AAAIgAAACMAAAAkAAAAJQAAAP7////+/////v////7////+/////v////7////+////LgAAAP7////+/////v' + ` + '////7////+/////v////7///82AAAANwAAADgAAAA5AAAAOgAAADsAAAA8AAAAPQAAAD4AAAD+////QAAAAEE' + ` + 'AAABCAAAAQwAAAEQAAABFAAAARgAAAEcAAABIAAAASQAAAEoAAABLAAAA/v//////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '/////////////////////////////////////////////////////////////////////////////////////' + ` + '///////////////////7/AAAGAQIAAAAAAAAAAAAAAAAAAAAAAAEAAADghZ/y+U9oEKuRCAArJ7PZMAAAAAwC' + ` + 'AAAOAAAAAQAAAHgAAAACAAAAgAAAAAMAAACgAAAABAAAAMQAAAAFAAAA9AAAAAYAAAAIAQAABwAAAGwBAAAJA' + ` + 'AAAgAEAAAwAAACwAQAADQAAALwBAAAOAAAAyAEAAA8AAADQAQAAEgAAANgBAAATAAAABAIAAAIAAADkBAAAHg' + ` + 'AAABYAAABJbnN0YWxsYXRpb24gRGF0YWJhc2UAAAAeAAAAGwAAAEEgcGFja2FnZSBmb3IgdW5pdCB0ZXN0aW5' + ` + 'nAAAeAAAAKAAAAE1pY3Jvc29mdCBVbml0IFRlc3RpbmcgR3VpbGQgb2YgQW1lcmljYQAeAAAACgAAAEluc3Rh' + ` + 'bGxlcgAAAB4AAABcAAAAVGhpcyBpbnN0YWxsZXIgZGF0YWJhc2UgY29udGFpbnMgdGhlIGxvZ2ljIGFuZCBkY' + ` + 'XRhIHJlcXVpcmVkIHRvIGluc3RhbGwgRFNDVW5pdFRlc3RQYWNrYWdlLgAeAAAACwAAAEludGVsOzEwMzMAAB' + ` + '4AAAAnAAAAe0YxN0FGREExLUREMEItNDRFNi1CNDczLTlFQkUyREJEOUVBOX0AAEAAAAAAAOO0qh/OAUAAAAA' + ` + 'AAOO0qh/OAQMAAADIAAAAAwAAAAIAAAAeAAAAIwAAAFdpbmRvd3MgSW5zdGFsbGVyIFhNTCAoMy43LjEyMDQu' + ` + 'MCkAAAMAAAACAAAAAAAAAAYABgAGAAYABgAGAAYABgAGAAYACgAKACIAIgAiACkAKQApACoAKgAqACsAKwArA' + ` + 'CsAKwArADEAMQAxAD4APgA+AD4APgA+AD4APgBNAE0AUgBSAFIAUgBSAFIAUgBSAGAAYABgAGEAYQBhAGIAYg' + ` + 'BmAGYAZgBmAGYAZgByAHIAdgB2AHYAdgB2AHYAgACAAIAAgACAAIAAgAACAAUACwAMAA0ADgAPABAAEQASAAc' + ` + 'ACQAjACUAJwAjACUAJwAjACUAJwAlACsALQAwADMANgAxADoAPAALADAAMwA+AEAAQgBFAEcATgBQACcAMwBQ' + ` + 'AFIAVQBYAFoAXAAjACUAJwAjACUAJwALACUAZwBpAGsAbQBvAHEABwByAAEABwBQAHYAeAB6ADMAXACBAIMAh' + ` + 'QCJAIsACAAIABgAGAAYABgAGAAIABgAGAAIAAgACAAYABgACAAYABgACAAYABgAGAAIABgACAAIABgACAAYAA' + ` + 'gAGAAYAAgACAAYABgAGAAIAAgACAAIABgACAAIAAgACAAYABgACAAYABgACAAYABgACAAIAAgACAAYABgAGAA' + ` + 'YAAgACAAYABgACAAIAAgACAAIABgACAAYABgAGAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAgAEAAAAAAAAA' + ` + 'AAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAA/P//fwAAAAAAAAAA/P//fwAAAAAAAAAA/P//fwAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAACAAAAAAAAAAA' + ` + 'ABAACAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAA/P//fwAAAAAAAAAA/P//fwAAAAAAAAA' + ` + 'AAQAAgAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////fwAAAAAAAACAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAACA/////wAAAAAAAAAA/////wAAA' + ` + 'AAAAAAAAAAAAAAAAAD/fwCAAAAAAAAAAAD/fwCAAAAAAAAAAAD/fwCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/38AgP9/AIAAAAAAAAAAAP//////fwCAAAA' + ` + 'AAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAD/fwCAAAAAAAAAAAD/fwCAAAAAAAAAAAD/fwCA/////wAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAACAAAAAAP////8AAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxAAAANw' + ` + 'AAADEAAAAAADEAAAAAAD4AAAAAAAAAPgArAAAAAAArAAAAAAAAAFIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAArAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAGAAAABgAAAAAABgAAAAAABgAAAAAAAAAGAAYAAAAAAAYAAAAAAA' + ` + 'AABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAB' + ` + 'MAEwAfAB8AAAAAAAAAAAATAAAAAAAAABMAJQAAABMAJQAAABMAJQAAACUAEwAuABMAAAATABMAEwA8AB8ASQA' + ` + 'AABMAEwAfAAAAAAATABMAAAAAABMAEwBWAAAAWgBcABMAJQAAABMAJQAAAGQAJQAAAAAAHwBtAB8AcgAfABMA' + ` + 'ZABkABMAEwAAAHsAAABcAC4AHwAfAGQASQAAAAAAAAAAAB0AAAAAABYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAVACEAIAAeABw' + ` + 'AGgAXABsAGQAAAAAAJAAmACgAJAAmACgAJAAmACgANQAsAC8AMgA0ADgAOQA7AD0ARABKAEwAPwBBAEMARgBI' + ` + 'AE8AUQBfAF4AVABTAFcAWQBbAF0AJAAmACgAJAAmACgAZQBjAGgAagBsAG4AcABzAHUAdAB9AH4AfwB3AHkAf' + ` + 'ACIAIcAggCEAIYAigCMAAAAAAAAAAAAjQCOAI8AkACRAJIAkwCUAAAAAAAAAAAAAAAAAAAAAAAgg4SD6IN4hd' + ` + 'yFPI+gj8iZAAAAAAAAAAAAAAAAAAAAAI0AjgCPAJUAAAAAAAAAAAAgg4SD6IMUhQAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACNAI8AkACRAJQAlgCXAAAAAAAAAAAAAAAAAAAAIIPog3iF3IXImZyY' + ` + 'AJkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmACZAJoABIAAAJsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJoAnACeAJwAngAAAJ0AnwCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAChAAAAogAAAAKAAYAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoQCYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI0AjgCPAJAAkQCUAJYAlwCjAKQApQCmAKcAqACpAKoAqwCsAK0AA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgg4SD6IN4hdyFyJmcmACZGYBkgLyCsIRAhg' + ` + 'iHKIqIk3CX1Jd5hQAAAAAAAAAAAAAAAAAAjQCOAI8AlQCjAKQApQCmAAAAAAAAAAAAAAAAAAAAAAAgg4SD6IM' + ` + 'UhRmAZIC8grCEAAAAAAAAAAAAAAAAAAAAAK4ArwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACBALAAsgC0ALYAuAC6AL0AvwC8ALEAswC1ALcAuQC7AL4AwAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmwACgMEAwgDDAJgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALwAvAAAALsAuwAAAAAAAAABAACAAgAAgAAAAADEAMUAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGACIAKQAqACsAMQA+AE0AUgBgAGEAYgBmAHIAdgCAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAGAAYABgAGAAYABgAGAAYABgAiACIAIgApACkAKQAqACoAK' + ` + 'gArACsAKwArACsAKwAxADEAMQA+AD4APgA+AD4APgA+AD4ATQBNAFIAUgBSAFIAUgBSAFIAUgBgAGAAYABhAG' + ` + 'EAYQBiAGIAZgBmAGYAZgBmAGYAcgByAHYAdgB2AHYAdgB2AIAAgACAAIAAgACAAIAAAYACgAOABIAFgAaAB4A' + ` + 'IgAmACoABgAKAA4ABgAKAA4ABgAKAA4ABgAKAA4AEgAWABoABgAKAA4ABgAKAA4AEgAWABoAHgAiAAYACgAGA' + ` + 'AoADgASABYAGgAeACIABgAKAA4ABgAKAA4ABgAKAAYACgAOABIAFgAaAAYACgAGAAoADgASABYAGgAGAAoADg' + ` + 'ASABYAGgAeAAgAFABAAEgAPABEADgANAAwACwAjACUAJwAjACUAJwAjACUAJwArAC0AMAAzACUANgAxADoAPA' + ` + 'A+AEAAQgALAEUARwAwADMATgBQAFIAUABVAFgAWgBcADMAJwAjACUAJwAjACUAJwAlAAsAZwBpAGsAbQBvAHE' + ` + 'AcgAHAHYAeAB6AAEABwBQAIEAgwCFAFwAMwCJAIsAIK0grQSNBJEEkf+dApUgnf+d/51Irf+dApVIrf+dApVI' + ` + 'rf+dApVIrSadSI0Chf+dSJ1IrUid/48mrSadQJ//nwKVAoVInQKFJq1IrUitSI3/jwSBSJ0UnQKVBIFIrf+dA' + ` + 'pVIrf+dApX/rf+PAqUEgUCf/50gnUidSK0Aj0itAoX/j/+fAJ9IjSatFL0Uvf+9BKH/nUiNAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAIABQACAAAAAAAAAAAABgACAAsAFQAFAAUAAQA' + ` + 'mAAoAAQATAAIACwAGAAMAAgAIAAIACQACAAgAAgBudGVnZXIgdG8gZGV0ZXJtaW5lIHNvcnQgb3JkZXIgZm9y' + ` + 'IHRhYmxlLkxhc3RTZXF1ZW5jZUZpbGUgc2VxdWVuY2UgbnVtYmVyIGZvciB0aGUgbGFzdCBmaWxlIGZvciB0a' + ` + 'GlzIG1lZGlhLkRpc2tQcm9tcHREaXNrIG5hbWU6IHRoZSB2aXNpYmxlIHRleHQgYWN0dWFsbHkgcHJpbnRlZC' + ` + 'BvbiB0aGUgZGlzay4gIFRoaXMgd2lsbCBiZSB1c2VkIHRvIHByb21wdCB0aGUgdXNlciB3aGVuIHRoaXMgZGl' + ` + 'zayBuZWVkcyB0byBiZSBpbnNlcnRlZC5DYWJpbmV0SWYgc29tZSBvciBhbGwgb2YgdGhlIGZpbGVzIHN0b3Jl' + ` + 'ZCBvbiB0aGUgbWVkaWEgYXJlIGNvbXByZXNzZWQgaW4gYSBjYWJpbmV0LCB0aGUgbmFtZSBvZiB0aGF0IGNhY' + ` + 'mluZXQuVm9sdW1lTGFiZWxUaGUgbGFiZWwgYXR0cmlidXRlZCB0byB0aGUgdm9sdW1lLlNvdXJjZVByb3Blcn' + ` + 'R5VGhlIHByb3BlcnR5IGRlZmluaW5nIHRoZSBsb2NhdGlvbiBvZiB0aGUgY2FiaW5ldCBmaWxlLk5hbWUgb2Y' + ` + 'gcHJvcGVydHksIHVwcGVyY2FzZSBpZiBzZXR0YWJsZSBieSBsYXVuY2hlciBvciBsb2FkZXIuU3RyaW5nIHZh' + ` + 'bHVlIGZvciBwcm9wZXJ0eS4gIE5ldmVyIG51bGwgb3IgZW1wdHkuUmVnaXN0cnlQcmltYXJ5IGtleSwgbm9uL' + ` + 'WxvY2FsaXplZCB0b2tlbi5Sb290VGhlIHByZWRlZmluZWQgcm9vdCBrZXkgZm9yIHRoZSByZWdpc3RyeSB2YW' + ` + 'x1ZSwgb25lIG9mIHJya0VudW0uS2V5UmVnUGF0aFRoZSBrZXkgZm9yIHRoZSByZWdpc3RyeSB2YWx1ZS5UaGU' + ` + 'gcmVnaXN0cnkgdmFsdWUgbmFtZS5UaGUgcmVnaXN0cnkgdmFsdWUuRm9yZWlnbiBrZXkgaW50byB0aGUgQ29t' + ` + 'cG9uZW50IHRhYmxlIHJlZmVyZW5jaW5nIGNvbXBvbmVudCB0aGF0IGNvbnRyb2xzIHRoZSBpbnN0YWxsaW5nI' + ` + 'G9mIHRoZSByZWdpc3RyeSB2YWx1ZS5VcGdyYWRlVXBncmFkZUNvZGVUaGUgVXBncmFkZUNvZGUgR1VJRCBiZW' + ` + 'xvbmdpbmcgdG8gdGhlIHByb2R1Y3RzIGluIHRoaXMgc2V0LlZlcnNpb25NaW5UaGUgbWluaW11bSBQcm9kdWN' + ` + '0VmVyc2lvbiBvZiB0aGUgcHJvZHVjdHMgaW4gdGhpcyBzZXQuICBUaGUgc2V0IG1heSBvciBtYXkgbm90IGlu' + ` + 'Y2x1ZGUgcHJvZHVjdHMgd2l0aCB0aGlzIHBhcnRpY3VsYXIgdmVyc2lvbi5WZXJzaW9uTWF4VGhlIG1heGltd' + ` + 'W0gUHJvZHVjdFZlcnNpb24gb2YgdGhlIHByb2R1Y3RzIGluIHRoaXMgc2V0LiAgVGhlIHNldCBtYXkgb3IgbW' + ` + 'F5IG5vdCBpbmNsdWRlIHByb2R1Y3RzIHdpdGggdGhpcyBwYXJ0aWN1bGFyIHZlcnNpb24uQSBjb21tYS1zZXB' + ` + 'hcmF0ZWQgbGlzdCBvZiBsYW5ndWFnZXMgZm9yIGVpdGhlciBwcm9kdWN0cyBpbiB0aGlzIHNldCBvciBwcm9k' + ` + 'dWN0cyBub3QgaW4gdGhpcyBzZXQuVGhlIGF0dHJpYnV0ZXMgb2YgdGhpcyBwcm9kdWN0IHNldC5SZW1vdmVUa' + ` + 'GUgbGlzdCBvZiBmZWF0dXJlcyB0byByZW1vdmUgd2hlbiB1bmluc3RhbGxpbmcgYSBwcm9kdWN0IGZyb20gdG' + ` + 'hpcyBzZXQuICBUaGUgZGVmYXVsdCBpcyAiQUxMIi5BY3Rpb25Qcm9wZXJ0eVRoZSBwcm9wZXJ0eSB0byBzZXQ' + ` + 'gd2hlbiBhIHByb2R1Y3QgaW4gdGhpcyBzZXQgaXMgZm91bmQuQ29zdEluaXRpYWxpemVGaWxlQ29zdENvc3RG' + ` + 'aW5hbGl6ZUluc3RhbGxWYWxpZGF0ZUluc3RhbGxJbml0aWFsaXplSW5zdGFsbEFkbWluUGFja2FnZUluc3Rhb' + ` + 'GxGaWxlc0luc3RhbGxGaW5hbGl6ZUV4ZWN1dGVBY3Rpb25QdWJsaXNoRmVhdHVyZXNQdWJsaXNoUHJvZHVjdF' + ` + 'Byb2R1Y3RDb21wb25lbnR7OTg5QjBFRDgtREVBRC01MjhELUI4RTMtN0NBRTQxODYyNEQ1fUlOU1RBTExGT0x' + ` + 'ERVJEdW1teUZsYWdWYWx1ZVByb2dyYW1GaWxlc0ZvbGRlcnE0cGZqNHo3fERTQ1NldHVwUHJvamVjdFRBUkdF' + ` + 'VERJUi5Tb3VyY2VEaXJQcm9kdWN0RmVhdHVyZURTQ1NldHVwUHJvamVjdEZpbmRSZWxhdGVkUHJvZHVjdHNMY' + ` + 'XVuY2hDb25kaXRpb25zVmFsaWRhdGVQcm9kdWN0SURNaWdyYXRlRmVhdHVyZVN0YXRlc1Byb2Nlc3NDb21wb2' + ` + '5lbnRzVW5wdWJsaXNoRmVhdHVyZXNSZW1vdmVSZWdpc3RyeVZhbHVlc1dyaXRlUmVnaXN0cnlWYWx1ZXNSZWd' + ` + 'pc3RlclVzZXJSZWdpc3RlclByb2R1Y3RSZW1vdmVFeGlzdGluZ1Byb2R1Y3RzTk9UIFdJWF9ET1dOR1JBREVf' + ` + 'REVURUNURURBIG5ld2VyIHZlcnNpb24gb2YgW1Byb2R1Y3ROYW1lXSBpcyBhbHJlYWR5IGluc3RhbGxlZC5BT' + ` + 'ExVU0VSUzFNYW51ZmFjdHVyZXJNaWNyb3NvZnQgVW5pdCBUZXN0aW5nIEd1aWxkIG9mIEFtZXJpY2FQcm9kdW' + ` + 'N0Q29kZXtERUFEQkVFRi04MEM2LTQxRTYtQTFCOS04QkRCOEEwNTAyN0Z9UHJvZHVjdExhbmd1YWdlMTAzM1B' + ` + 'yb2R1Y3ROYW1lRFNDVW5pdFRlc3RQYWNrYWdlUHJvZHVjdFZlcnNpb24xLjIuMy40ezgzQkMzNzkyLTgwQzYt' + ` + 'NDFFNi1BMUI5LThCREI4QTA1MDI3Rn1TZWN1cmVDdXN0b21Qcm9wZXJ0aWVzV0lYX0RPV05HUkFERV9ERVRFQ' + ` + '1RFRDtXSVhfVVBHUkFERV9ERVRFQ1RFRFdpeFBkYlBhdGhDOlxVc2Vyc1xiZWNhcnJcRG9jdW1lbnRzXFZpc3' + ` + 'VhbCBTdHVkaW8gMjAxMFxQcm9qZWN0c1xEU0NTZXR1cFByb2plY3RcRFNDU2V0dXBQcm9qZWN0XGJpblxEZWJ' + ` + '1Z1xEU0NTZXR1cFByb2plY3Qud2l4cGRiU29mdHdhcmVcRFNDVGVzdERlYnVnRW50cnlbfl1EVU1NWUZMQUc9' + ` + 'W0RVTU1ZRkxBR11bfl1XSVhfVVBHUkFERV9ERVRFQ1RFRFdJWF9ET1dOR1JBREVfREVURUNURURzZWQgdG8gZ' + ` + 'm9yY2UgYSBzcGVjaWZpYyBkaXNwbGF5IG9yZGVyaW5nLkxldmVsVGhlIGluc3RhbGwgbGV2ZWwgYXQgd2hpY2' + ` + 'ggcmVjb3JkIHdpbGwgYmUgaW5pdGlhbGx5IHNlbGVjdGVkLiBBbiBpbnN0YWxsIGxldmVsIG9mIDAgd2lsbCB' + ` + 'kaXNhYmxlIGFuIGl0ZW0gYW5kIHByZXZlbnQgaXRzIGRpc3BsYXkuVXBwZXJDYXNlVGhlIG5hbWUgb2YgdGhl' + ` + 'IERpcmVjdG9yeSB0aGF0IGNhbiBiZSBjb25maWd1cmVkIGJ5IHRoZSBVSS4gQSBub24tbnVsbCB2YWx1ZSB3a' + ` + 'WxsIGVuYWJsZSB0aGUgYnJvd3NlIGJ1dHRvbi4wOzE7Mjs0OzU7Njs4Ozk7MTA7MTY7MTc7MTg7MjA7MjE7Mj' + ` + 'I7MjQ7MjU7MjY7MzI7MzM7MzQ7MzY7Mzc7Mzg7NDg7NDk7NTA7NTI7NQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATmFtZVRhYmxlQ29sdW1uX1Zh' + ` + 'bGlkYXRpb25WYWx1ZU5Qcm9wZXJ0eUlkX1N1bW1hcnlJbmZvcm1hdGlvbkRlc2NyaXB0aW9uU2V0Q2F0ZWdvc' + ` + 'nlLZXlDb2x1bW5NYXhWYWx1ZU51bGxhYmxlS2V5VGFibGVNaW5WYWx1ZUlkZW50aWZpZXJOYW1lIG9mIHRhYm' + ` + 'xlTmFtZSBvZiBjb2x1bW5ZO05XaGV0aGVyIHRoZSBjb2x1bW4gaXMgbnVsbGFibGVZTWluaW11bSB2YWx1ZSB' + ` + 'hbGxvd2VkTWF4aW11bSB2YWx1ZSBhbGxvd2VkRm9yIGZvcmVpZ24ga2V5LCBOYW1lIG9mIHRhYmxlIHRvIHdo' + ` + 'aWNoIGRhdGEgbXVzdCBsaW5rQ29sdW1uIHRvIHdoaWNoIGZvcmVpZ24ga2V5IGNvbm5lY3RzVGV4dDtGb3JtY' + ` + 'XR0ZWQ7VGVtcGxhdGU7Q29uZGl0aW9uO0d1aWQ7UGF0aDtWZXJzaW9uO0xhbmd1YWdlO0lkZW50aWZpZXI7Qm' + ` + 'luYXJ5O1VwcGVyQ2FzZTtMb3dlckNhc2U7RmlsZW5hbWU7UGF0aHM7QW55UGF0aDtXaWxkQ2FyZEZpbGVuYW1' + ` + 'lO1JlZ1BhdGg7Q3VzdG9tU291cmNlO1Byb3BlcnR5O0NhYmluZXQ7U2hvcnRjdXQ7Rm9ybWF0dGVkU0RETFRl' + ` + 'eHQ7SW50ZWdlcjtEb3VibGVJbnRlZ2VyO1RpbWVEYXRlO0RlZmF1bHREaXJTdHJpbmcgY2F0ZWdvcnlUZXh0U' + ` + '2V0IG9mIHZhbHVlcyB0aGF0IGFyZSBwZXJtaXR0ZWREZXNjcmlwdGlvbiBvZiBjb2x1bW5BZG1pbkV4ZWN1dG' + ` + 'VTZXF1ZW5jZUFjdGlvbk5hbWUgb2YgYWN0aW9uIHRvIGludm9rZSwgZWl0aGVyIGluIHRoZSBlbmdpbmUgb3I' + ` + 'gdGhlIGhhbmRsZXIgRExMLkNvbmRpdGlvbk9wdGlvbmFsIGV4cHJlc3Npb24gd2hpY2ggc2tpcHMgdGhlIGFj' + ` + 'dGlvbiBpZiBldmFsdWF0ZXMgdG8gZXhwRmFsc2UuSWYgdGhlIGV4cHJlc3Npb24gc3ludGF4IGlzIGludmFsa' + ` + 'WQsIHRoZSBlbmdpbmUgd2lsbCB0ZXJtaW5hdGUsIHJldHVybmluZyBpZXNCYWRBY3Rpb25EYXRhLlNlcXVlbm' + ` + 'NlTnVtYmVyIHRoYXQgZGV0ZXJtaW5lcyB0aGUgc29ydCBvcmRlciBpbiB3aGljaCB0aGUgYWN0aW9ucyBhcmU' + ` + 'gdG8gYmUgZXhlY3V0ZWQuICBMZWF2ZSBibGFuayB0byBzdXBwcmVzcyBhY3Rpb24uQWRtaW5VSVNlcXVlbmNl' + ` + 'QWR2dEV4ZWN1dGVTZXF1ZW5jZUNvbXBvbmVudFByaW1hcnkga2V5IHVzZWQgdG8gaWRlbnRpZnkgYSBwYXJ0a' + ` + 'WN1bGFyIGNvbXBvbmVudCByZWNvcmQuQ29tcG9uZW50SWRHdWlkQSBzdHJpbmcgR1VJRCB1bmlxdWUgdG8gdG' + ` + 'hpcyBjb21wb25lbnQsIHZlcnNpb24sIGFuZCBsYW5ndWFnZS5EaXJlY3RvcnlfRGlyZWN0b3J5UmVxdWlyZWQ' + ` + 'ga2V5IG9mIGEgRGlyZWN0b3J5IHRhYmxlIHJlY29yZC4gVGhpcyBpcyBhY3R1YWxseSBhIHByb3BlcnR5IG5h' + ` + 'bWUgd2hvc2UgdmFsdWUgY29udGFpbnMgdGhlIGFjdHVhbCBwYXRoLCBzZXQgZWl0aGVyIGJ5IHRoZSBBcHBTZ' + ` + 'WFyY2ggYWN0aW9uIG9yIHdpdGggdGhlIGRlZmF1bHQgc2V0dGluZyBvYnRhaW5lZCBmcm9tIHRoZSBEaXJlY3' + ` + 'RvcnkgdGFibGUuQXR0cmlidXRlc1JlbW90ZSBleGVjdXRpb24gb3B0aW9uLCBvbmUgb2YgaXJzRW51bUEgY29' + ` + 'uZGl0aW9uYWwgc3RhdGVtZW50IHRoYXQgd2lsbCBkaXNhYmxlIHRoaXMgY29tcG9uZW50IGlmIHRoZSBzcGVj' + ` + 'aWZpZWQgY29uZGl0aW9uIGV2YWx1YXRlcyB0byB0aGUgJ1RydWUnIHN0YXRlLiBJZiBhIGNvbXBvbmVudCBpc' + ` + 'yBkaXNhYmxlZCwgaXQgd2lsbCBub3QgYmUgaW5zdGFsbGVkLCByZWdhcmRsZXNzIG9mIHRoZSAnQWN0aW9uJy' + ` + 'BzdGF0ZSBhc3NvY2lhdGVkIHdpdGggdGhlIGNvbXBvbmVudC5LZXlQYXRoRmlsZTtSZWdpc3RyeTtPREJDRGF' + ` + '0YVNvdXJjZUVpdGhlciB0aGUgcHJpbWFyeSBrZXkgaW50byB0aGUgRmlsZSB0YWJsZSwgUmVnaXN0cnkgdGFi' + ` + 'bGUsIG9yIE9EQkNEYXRhU291cmNlIHRhYmxlLiBUaGlzIGV4dHJhY3QgcGF0aCBpcyBzdG9yZWQgd2hlbiB0a' + ` + 'GUgY29tcG9uZW50IGlzIGluc3RhbGxlZCwgYW5kIGlzIHVzZWQgdG8gZGV0ZWN0IHRoZSBwcmVzZW5jZSBvZi' + ` + 'B0aGUgY29tcG9uZW50IGFuZCB0byByZXR1cm4gdGhlIHBhdGggdG8gaXQuVW5pcXVlIGlkZW50aWZpZXIgZm9' + ` + 'yIGRpcmVjdG9yeSBlbnRyeSwgcHJpbWFyeSBrZXkuIElmIGEgcHJvcGVydHkgYnkgdGhpcyBuYW1lIGlzIGRl' + ` + 'ZmluZWQsIGl0IGNvbnRhaW5zIHRoZSBmdWxsIHBhdGggdG8gdGhlIGRpcmVjdG9yeS5EaXJlY3RvcnlfUGFyZ' + ` + 'W50UmVmZXJlbmNlIHRvIHRoZSBlbnRyeSBpbiB0aGlzIHRhYmxlIHNwZWNpZnlpbmcgdGhlIGRlZmF1bHQgcG' + ` + 'FyZW50IGRpcmVjdG9yeS4gQSByZWNvcmQgcGFyZW50ZWQgdG8gaXRzZWxmIG9yIHdpdGggYSBOdWxsIHBhcmV' + ` + 'udCByZXByZXNlbnRzIGEgcm9vdCBvZiB0aGUgaW5zdGFsbCB0cmVlLkRlZmF1bHREaXJUaGUgZGVmYXVsdCBz' + ` + 'dWItcGF0aCB1bmRlciBwYXJlbnQncyBwYXRoLkZlYXR1cmVQcmltYXJ5IGtleSB1c2VkIHRvIGlkZW50aWZ5I' + ` + 'GEgcGFydGljdWxhciBmZWF0dXJlIHJlY29yZC5GZWF0dXJlX1BhcmVudE9wdGlvbmFsIGtleSBvZiBhIHBhcm' + ` + 'VudCByZWNvcmQgaW4gdGhlIHNhbWUgdGFibGUuIElmIHRoZSBwYXJlbnQgaXMgbm90IHNlbGVjdGVkLCB0aGV' + ` + 'uIHRoZSByZWNvcmQgd2lsbCBub3QgYmUgaW5zdGFsbGVkLiBOdWxsIGluZGljYXRlcyBhIHJvb3QgaXRlbS5U' + ` + 'aXRsZVNob3J0IHRleHQgaWRlbnRpZnlpbmcgYSB2aXNpYmxlIGZlYXR1cmUgaXRlbS5Mb25nZXIgZGVzY3Jpc' + ` + 'HRpdmUgdGV4dCBkZXNjcmliaW5nIGEgdmlzaWJsZSBmZWF0dXJlIGl0ZW0uRGlzcGxheU51bWVyaWMgc29ydC' + ` + 'BvcmRlciwgdXNlZCB0byBmb3JjZSBhIHNwZWNpZmljIGRpc3BsYXkgb3JkZXJpbmcuTGV2ZWxUaGUgaW5zdGF' + ` + 'sbCBsZXZlbCBhdCB3aGljaCByZWNvcmQgd2lsbCBiZSBpbml0aWFsbHkgc2VsZWN0ZWQuIEFuIGluc3RhbGwg' + ` + 'bGV2ZWwgb2YgMCB3aWxsIGRpc2FibGUgYW4gaXRlbSBhbmQgcHJldmVudCBpdHMgZGlzcGxheS5VcHBlckNhc' + ` + '2VUaGUgbmFtZSBvZiB0aGUgRGlyZWN0b3J5IHRoYXQgY2FuIGJlIGNvbmZpZ3VyZWQgYnkgdGhlIFVJLiBBIG' + ` + '5vbi1udWxsIHZhbHVlIHdpbGwgZW5hYmxlIHRoZSBicm93c2UgYnV0dG9uLjA7MTsyOzQ7NTs2Ozg7OTsxMDs' + ` + 'xNjsxNzsxODsyMDsyMTsyMjsyNDsyNTsyNjszMjszMzszNDszNjszNzszODs0ODs0OTs1MDs1Mjs1Mzs1NEZl' + ` + 'YXR1cmUgYXR0cmlidXRlc0ZlYXR1cmVDb21wb25lbnRzRmVhdHVyZV9Gb3JlaWduIGtleSBpbnRvIEZlYXR1c' + ` + 'mUgdGFibGUuQ29tcG9uZW50X0ZvcmVpZ24ga2V5IGludG8gQ29tcG9uZW50IHRhYmxlLkZpbGVQcmltYXJ5IG' + ` + 'tleSwgbm9uLWxvY2FsaXplZCB0b2tlbiwgbXVzdCBtYXRjaCBpZGVudGlmaWVyIGluIGNhYmluZXQuICBGb3I' + ` + 'gdW5jb21wcmVzc2VkIGZpbGVzLCB0aGlzIGZpZWxkIGlzIGlnbm9yZWQuRm9yZWlnbiBrZXkgcmVmZXJlbmNp' + ` + 'bmcgQ29tcG9uZW50IHRoYXQgY29udHJvbHMgdGhlIGZpbGUuRmlsZU5hbWVGaWxlbmFtZUZpbGUgbmFtZSB1c' + ` + '2VkIGZvciBpbnN0YWxsYXRpb24sIG1heSBiZSBsb2NhbGl6ZWQuICBUaGlzIG1heSBjb250YWluIGEgInNob3' + ` + 'J0IG5hbWV8bG9uZyBuYW1lIiBwYWlyLkZpbGVTaXplU2l6ZSBvZiBmaWxlIGluIGJ5dGVzIChsb25nIGludGV' + ` + 'nZXIpLlZlcnNpb25WZXJzaW9uIHN0cmluZyBmb3IgdmVyc2lvbmVkIGZpbGVzOyAgQmxhbmsgZm9yIHVudmVy' + ` + 'c2lvbmVkIGZpbGVzLkxhbmd1YWdlTGlzdCBvZiBkZWNpbWFsIGxhbmd1YWdlIElkcywgY29tbWEtc2VwYXJhd' + ` + 'GVkIGlmIG1vcmUgdGhhbiBvbmUuSW50ZWdlciBjb250YWluaW5nIGJpdCBmbGFncyByZXByZXNlbnRpbmcgZm' + ` + 'lsZSBhdHRyaWJ1dGVzICh3aXRoIHRoZSBkZWNpbWFsIHZhbHVlIG9mIGVhY2ggYml0IHBvc2l0aW9uIGluIHB' + ` + 'hcmVudGhlc2VzKVNlcXVlbmNlIHdpdGggcmVzcGVjdCB0byB0aGUgbWVkaWEgaW1hZ2VzOyBvcmRlciBtdXN0' + ` + 'IHRyYWNrIGNhYmluZXQgb3JkZXIuSW5zdGFsbEV4ZWN1dGVTZXF1ZW5jZUluc3RhbGxVSVNlcXVlbmNlTGF1b' + ` + 'mNoQ29uZGl0aW9uRXhwcmVzc2lvbiB3aGljaCBtdXN0IGV2YWx1YXRlIHRvIFRSVUUgaW4gb3JkZXIgZm9yIG' + ` + 'luc3RhbGwgdG8gY29tbWVuY2UuRm9ybWF0dGVkTG9jYWxpemFibGUgdGV4dCB0byBkaXNwbGF5IHdoZW4gY29' + ` + 'uZGl0aW9uIGZhaWxzIGFuZCBpbnN0YWxsIG11c3QgYWJvcnQuTWVkaWFEaXNrSWRQcmltYXJ5IGtleSwgaQgA' + ` + 'AgAIAAIACAACAAoAFgANAAEADgABAAMAAQAeAAEAAQAnABUAAQAVAAEANgABACQAAQD1AAEADwABAAQACQAgA' + ` + 'AEAFQABABQABwAGAAoAQgAFAAkAFQCfAAUACAAMAG8ABQAPAAcAEwAHAAkAEgA7AAEACwACAAQAAgA+AAEACg' + ` + 'AEAAkADADSAAEACgAIACcAAQDoAAEABwACABwAAQDjAAEAhgABABAAAgCmAAEACgADACkAAQAHABUAOQABAA4' + ` + 'AAgCUAAEABQACAC4AAQA6AAEABwACAD4AAQAFAAIAgQABAAkAAgBrAAEAUQABABIAAQARAAUACAACAB8AAQAK' + ` + 'AAYAIQABAAQAFABzAAEAOQABAAgAAgAIAAEAYwABAAgAAgAlAAEABwADAEEAAQAIAAYAPwABAHYAAQBKAAEAF' + ` + 'gAHABEABwAPAAUASAABAAkABABIAAEABQANAAYAAgA3AAEADAACADYAAQAKAAIAhAABAAcAAwBmAAEACwACAC' + ` + 'MAAQAGAAIACAAIADcAAQA+AAEAMAABAAgADwAhAAEABAACAD8AAQADAAIABwABAB8AAQAYAAEAEwABAG4AAQA' + ` + 'HAA8ACwADADsAAQAKAAIAfgABAAoAAgB+AAEAYAABACMAAQAGAAIAYAABAA4AAgA4AAEADgAFAAgABAAMAAUA' + ` + 'DwADABEAAwATAAEADAABAA8AAwANAAIADwACAA4AAgAQAAMAJgABAA0AAgAOAAIAEgACABgAAQAJAAIAAQABA' + ` + 'AkAAQAOAAIADwABABMAAgAQAAIAEQACABQAAgARAAEAEQABABQAAQATAAEADAABAA8AAQAWAAEAGgABADYAAQ' + ` + 'AIAAEAAQABAAwAAQAnAAEACwABACYAAQAPAAEABAABAAsAAQASAAEADgABAAcAAwAmAAMAFgABACsAAQAKAAE' + ` + 'AdgABABAAAQAKAAEAGwABABQAAQAWAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ` + 'AAAAAAAAAAAAAAAAAAA=' + #endregion + + $msiContentInBytes = [System.Convert]::FromBase64String($msiContentInBase64) + + Set-Content -Path $DestinationPath -Value $msiContentInBytes -Encoding 'Byte' | Out-Null +} + +Export-ModuleMember -Function ` + New-TestMsi, ` + Start-Server, ` + Stop-Server, ` + Test-PackageInstalledById diff --git a/Tests/Unit/MSFT_MsiPackage.Tests.ps1 b/Tests/Unit/MSFT_MsiPackage.Tests.ps1 new file mode 100644 index 0000000..9101cb0 --- /dev/null +++ b/Tests/Unit/MSFT_MsiPackage.Tests.ps1 @@ -0,0 +1,1011 @@ +$errorActionPreference = 'Stop' +Set-StrictMode -Version 'Latest' + +Describe 'MsiPackage Unit Tests' { + BeforeAll { + # Import CommonTestHelper + $testsFolderFilePath = Split-Path $PSScriptRoot -Parent + $testHelperFolderFilePath = Join-Path -Path $testsFolderFilePath -ChildPath 'TestHelpers' + $commonTestHelperFilePath = Join-Path -Path $testHelperFolderFilePath -ChildPath 'CommonTestHelper.psm1' + Import-Module -Name $commonTestHelperFilePath + + $script:testEnvironment = Enter-DscResourceTestEnvironment ` + -DscResourceModuleName 'PSDscResources' ` + -DscResourceName 'MSFT_MsiPackage' ` + -TestType 'Unit' + } + + AfterAll { + Exit-DscResourceTestEnvironment -TestEnvironment $script:testEnvironment + } + + InModuleScope 'MSFT_MsiPackage' { + $script:testsFolderFilePath = Split-Path $PSScriptRoot -Parent + $testHelperFolderFilePath = Join-Path -Path $testsFolderFilePath -ChildPath 'TestHelpers' + $script:commonTestHelperFilePath = Join-Path -Path $testHelperFolderFilePath -ChildPath 'CommonTestHelper.psm1' + + # This must be imported again within the InModuleScope so that the helper functions can be accessed + Import-Module -Name $commonTestHelperFilePath + + $testUsername = 'TestUsername' + $testPassword = 'TestPassword' + $secureTestPassword = ConvertTo-SecureString -String $testPassword -AsPlainText -Force + + $script:testCredential = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList @( $testUsername, $secureTestPassword ) + $script:testProductId = '{deadbeef-80c6-41e6-a1b9-8bdb8a05027f}' + $script:testIdentifyingNumber = '{DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F}' + $script:testWrongProductId = 'wrongId' + $script:testPath = 'file://test.msi' + $script:destinationPath = Join-Path -Path $script:packageCacheLocation -ChildPath 'C:\' + $script:testUriHttp = [Uri] 'http://test.msi' + $script:testUriHttps = [Uri] 'https://test.msi' + $script:testUriFile = [Uri] 'file://test.msi' + $script:testUriNonUnc = [Uri] 'file:///C:/test.msi' + $script:testUriQuery = [Uri] 'http://C:/directory/test/test.msi?sv=2017-01-31&spr=https' + $script:testUriOnlyFile = [Uri] 'test.msi' + + $script:mockStream = New-MockObject -Type 'System.IO.FileStream' + $script:mockWebRequest = New-MockObject -Type 'System.Net.HttpWebRequest' + $script:mockStartInfo = New-MockObject -Type 'System.Diagnostics.ProcessStartInfo' + $script:mockProcess = New-MockObject -Type 'System.Diagnostics.Process' + $script:mockProductEntry = New-MockObject -Type 'Microsoft.Win32.RegistryKey' + + $script:mockPSDrive = @{ + Root = 'mockRoot' + } + + $script:mockProductEntryInfo = @{ + Name = 'TestDisplayName' + InstallSource = 'TestInstallSource' + InstalledOn = '4/4/2017' + Size = 2048 + Version = '1.2.3.4' + PackageDescription = 'Test Description' + Publisher = 'Test Publisher' + Ensure = 'Present' + } + + # Used to create the names of the tests that check to ensure the correct error is thrown. + $script:errorMessageTitles = @{ + CouldNotOpenLog = 'not being able to open the log path' + InvalidId = 'the specified product ID not matching the actual product ID' + CouldNotOpenDestFile = 'not being able to open the destination file to write to' + PathDoesNotExist = 'not being able to find the path' + CouldNotStartProcess = 'not being able to start the process' + PostValidationError = 'not being able to find the package after installation' + } + + Describe 'Get-TargetResource' { + Mock -CommandName 'Convert-ProductIdToIdentifyingNumber' -MockWith { return $script:testIdentifyingNumber } + Mock -CommandName 'Get-ProductEntry' -MockWith { return $null } + Mock -CommandName 'Get-ProductEntryInfo' -MockWith { return $script:mockProductEntryInfo } + + Context 'MSI package does not exist' { + $getTargetResourceParameters = @{ + ProductId = $script:testProductId + Path = $script:testPath + } + + $mocksCalled = @( + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Get-ProductEntry'; Times = 1 } + @{ Command = 'Get-ProductEntryInfo'; Times = 0 } + ) + + $expectedReturnValue = @{ + Ensure = 'Absent' + ProductId = $script:testIdentifyingNumber + } + + Invoke-GetTargetResourceUnitTest -GetTargetResourceParameters $getTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ExpectedReturnValue $expectedReturnValue + } + + Mock -CommandName 'Get-ProductEntry' -MockWith { return $script:mockProductEntry } + + Context 'MSI package does exist' { + $getTargetResourceParameters = @{ + ProductId = $script:testProductId + Path = $script:testPath + } + + $mocksCalled = @( + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Get-ProductEntry'; Times = 1 } + @{ Command = 'Get-ProductEntryInfo'; Times = 1 } + ) + + $expectedReturnValue = $script:mockProductEntryInfo + + Invoke-GetTargetResourceUnitTest -GetTargetResourceParameters $getTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ExpectedReturnValue $expectedReturnValue + } + } + + Describe 'Set-TargetResource' { + $setTargetResourceParameters = @{ + ProductId = 'TestProductId' + Path = $script:testPath + Ensure = 'Present' + Arguments = 'TestArguments' + LogPath = 'TestLogPath' + FileHash = 'TestFileHash' + HashAlgorithm = 'Sha256' + SignerSubject = 'TestSignerSubject' + SignerThumbprint = 'TestSignerThumbprint' + ServerCertificateValidationCallback = 'TestValidationCallback' + RunAsCredential = $script:testCredential + } + + Mock -CommandName 'Convert-PathToUri' -MockWith { return $script:testUriNonUnc } + Mock -CommandName 'Convert-ProductIdToIdentifyingNumber' -MockWith { return $script:testIdentifyingNumber } + Mock -CommandName 'Assert-PathExtensionValid' -MockWith {} + Mock -CommandName 'New-LogFile' -MockWith {} + Mock -CommandName 'New-PSDrive' -MockWith { return $script:mockPSDrive } + Mock -CommandName 'Test-Path' -MockWith { return $true } + Mock -CommandName 'New-Item' -MockWith {} + Mock -CommandName 'New-Object' -MockWith { return $script:mockStream } #-ParameterFilter { $TypeName -eq 'System.IO.FileStream' } + Mock -CommandName 'Get-WebRequestResponse' -MockWith { return $script:mockStream } + Mock -CommandName 'Copy-ResponseStreamToFileStream' -MockWith {} + Mock -CommandName 'Close-Stream' -MockWith {} + Mock -CommandName 'Assert-FileValid' -MockWith {} + Mock -CommandName 'Get-MsiProductCode' -MockWith { return $script:testIdentifyingNumber } + Mock -CommandName 'Start-MsiProcess' -MockWith { return 0 } # returns the exit code + Mock -CommandName 'Remove-PSDrive' -MockWith {} + Mock -CommandName 'Remove-Item' -MockWith {} + Mock -CommandName 'Invoke-CimMethod' -MockWith {} + Mock -CommandName 'Get-ItemProperty' -MockWith { return $null } + Mock -CommandName 'Get-ProductEntry' -MockWith { return $script:mockProductEntry } + + Context 'Uri scheme is non-UNC file and installation succeeds' { + $mocksCalled = @( + @{ Command = 'Convert-PathToUri'; Times = 1 } + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Assert-PathExtensionValid'; Times = 1 } + @{ Command = 'New-LogFile'; Times = 1 } + @{ Command = 'New-PSDrive'; Times = 0 } + @{ Command = 'Get-WebRequestResponse'; Times = 0 } + @{ Command = 'Test-Path'; Times = 1; Custom = 'to the MSI file' } + @{ Command = 'Assert-FileValid'; Times = 1 } + @{ Command = 'Get-MsiProductCode'; Times = 1 } + @{ Command = 'Start-MsiProcess'; Times = 1 } + @{ Command = 'Remove-PSDrive'; Times = 0 } + @{ Command = 'Remove-Item'; Times = 0; Custom = 'the downloaded file from the http server' } + @{ Command = 'Invoke-CimMethod'; Times = 1 } + @{ Command = 'Get-ItemProperty'; Times = 1; Custom = 'the registry data for pending file rename operations' } + @{ Command = 'Get-ProductEntry'; Times = 1 } + ) + + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + Mock -CommandName 'Convert-PathToUri' -MockWith { return $script:testUriFile } + $setTargetResourceParameters.Ensure = 'Present' + + Context 'Uri scheme is UNC file and installation succeeds' { + $mocksCalled = @( + @{ Command = 'Convert-PathToUri'; Times = 1 } + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Assert-PathExtensionValid'; Times = 1 } + @{ Command = 'New-LogFile'; Times = 1 } + @{ Command = 'New-PSDrive'; Times = 1 } + @{ Command = 'Get-WebRequestResponse'; Times = 0 } + @{ Command = 'Test-Path'; Times = 1; Custom = 'to the MSI file' } + @{ Command = 'Assert-FileValid'; Times = 1 } + @{ Command = 'Get-MsiProductCode'; Times = 1 } + @{ Command = 'Start-MsiProcess'; Times = 1 } + @{ Command = 'Remove-PSDrive'; Times = 1 } + @{ Command = 'Remove-Item'; Times = 0; Custom = 'the downloaded file from the http server' } + @{ Command = 'Invoke-CimMethod'; Times = 1 } + @{ Command = 'Get-ItemProperty'; Times = 1; Custom = 'the registry data for pending file rename operations' } + @{ Command = 'Get-ProductEntry'; Times = 1 } + ) + + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + Mock -CommandName 'Convert-PathToUri' -MockWith { return $script:testUriHttp } + + Context 'Uri scheme is Http and installation succeeds' { + $mocksCalled = @( + @{ Command = 'Convert-PathToUri'; Times = 1 } + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Assert-PathExtensionValid'; Times = 1 } + @{ Command = 'New-LogFile'; Times = 1 } + @{ Command = 'New-PSDrive'; Times = 0 } + @{ Command = 'Test-Path'; Times = 2; Custom = 'to the package cache' } + @{ Command = 'New-Item'; Times = 0; Custom = 'directory for the package cache' } + @{ Command = 'New-Object'; Times = 1; Custom = 'file stream to copy the response to' } + @{ Command = 'Get-WebRequestResponse'; Times = 1 } + @{ Command = 'Copy-ResponseStreamToFileStream'; Times = 1 } + @{ Command = 'Close-Stream'; Times = 2 } + @{ Command = 'Test-Path'; Times = 2; Custom = 'to the MSI file' } + @{ Command = 'Assert-FileValid'; Times = 1 } + @{ Command = 'Get-MsiProductCode'; Times = 1 } + @{ Command = 'Start-MsiProcess'; Times = 1 } + @{ Command = 'Remove-PSDrive'; Times = 0 } + @{ Command = 'Remove-Item'; Times = 1; Custom = 'the directory used for the package cache' } + @{ Command = 'Invoke-CimMethod'; Times = 1 } + @{ Command = 'Get-ItemProperty'; Times = 1; Custom = 'the registry data for pending file rename operations' } + @{ Command = 'Get-ProductEntry'; Times = 1 } + ) + + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + Mock -CommandName 'Convert-PathToUri' -MockWith { return $script:testUriHttps } + + Context 'Uri scheme is Https and installation succeeds' { + $mocksCalled = @( + @{ Command = 'Convert-PathToUri'; Times = 1 } + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Assert-PathExtensionValid'; Times = 1 } + @{ Command = 'New-LogFile'; Times = 1 } + @{ Command = 'New-PSDrive'; Times = 0 } + @{ Command = 'Test-Path'; Times = 2; Custom = 'to the package cache' } + @{ Command = 'New-Item'; Times = 0; Custom = 'directory for the package cache' } + @{ Command = 'New-Object'; Times = 1; Custom = 'file stream to copy the response to' } + @{ Command = 'Get-WebRequestResponse'; Times = 1 } + @{ Command = 'Copy-ResponseStreamToFileStream'; Times = 1 } + @{ Command = 'Close-Stream'; Times = 2 } + @{ Command = 'Test-Path'; Times = 2; Custom = 'to the MSI file' } + @{ Command = 'Assert-FileValid'; Times = 1 } + @{ Command = 'Get-MsiProductCode'; Times = 1 } + @{ Command = 'Start-MsiProcess'; Times = 1 } + @{ Command = 'Remove-PSDrive'; Times = 0 } + @{ Command = 'Remove-Item'; Times = 1; Custom = 'the directory used for the package cache' } + @{ Command = 'Invoke-CimMethod'; Times = 1 } + @{ Command = 'Get-ItemProperty'; Times = 1; Custom = 'the registry data for pending file rename operations' } + @{ Command = 'Get-ProductEntry'; Times = 1 } + ) + + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + $setTargetResourceParameters.Ensure = 'Absent' + + # The URI scheme doesn't matter for uninstallation - it will always do the same thing + Context 'Uninstallation succeeds' { + $mocksCalled = @( + @{ Command = 'Convert-PathToUri'; Times = 1 } + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Assert-PathExtensionValid'; Times = 1 } + @{ Command = 'New-LogFile'; Times = 1 } + @{ Command = 'New-PSDrive'; Times = 0 } + @{ Command = 'Get-WebRequestResponse'; Times = 0 } + @{ Command = 'Test-Path'; Times = 0; Custom = 'to the MSI file' } + @{ Command = 'Assert-FileValid'; Times = 0 } + @{ Command = 'Get-MsiProductCode'; Times = 0 } + @{ Command = 'Start-MsiProcess'; Times = 1 } + @{ Command = 'Remove-PSDrive'; Times = 0 } + @{ Command = 'Remove-Item'; Times = 0; Custom = 'the downloaded file from the http server' } + @{ Command = 'Invoke-CimMethod'; Times = 1 } + @{ Command = 'Get-ItemProperty'; Times = 1; Custom = 'the registry data for pending file rename operations' } + @{ Command = 'Get-ProductEntry'; Times = 0 } + ) + + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + Mock -CommandName 'Convert-PathToUri' -MockWith { return $script:testUriQuery } + + Context 'Path is a query path' { + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -ShouldThrow $false + + It 'Should assert that the file without the query string has a valid extension' { + Assert-MockCalled -CommandName 'Assert-PathExtensionValid' -Exactly 1 -Scope 'Context' -ParameterFilter { $Path -eq (Split-Path -Path $script:testuriQuery.LocalPath -Leaf) } + } + } + + Mock -CommandName 'Convert-PathToUri' -MockWith { return $script:testUriOnlyFile } + + Context 'Converted URI does not have a local path' { + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -ShouldThrow $false + + It 'Should assert that the original path has a valid extension' { + Assert-MockCalled -CommandName 'Assert-PathExtensionValid' -Exactly 1 -Scope 'Context' -ParameterFilter { $Path -eq $script:testPath } + } + } + + $setTargetResourceParameters.Remove('LogPath') + + Context 'Converted URI does not have a local path' { + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -MocksCalled @(@{ Command = 'New-LogFile'; Times = 0 }) ` + -ShouldThrow $false + } + + Mock -CommandName 'Convert-PathToUri' -MockWith { return $script:testUriHttp } + Mock -CommandName 'Test-Path' -MockWith { return $false } -ParameterFilter { $Path -eq $script:packageCacheLocation } + $setTargetResourceParameters.Ensure = 'Present' + + Context 'URI scheme is Http and package cache location does not exist yet' { + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -MocksCalled @(@{ Command = 'New-Item'; Times = 1; Custom = 'directory for the package cache' }) ` + -ShouldThrow $false + } + + # Error Tests + + Mock -CommandName 'Get-ProductEntry' -MockWith { return $null } + + Context 'Package could not be found after installation' { + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -ShouldThrow $true ` + -ErrorMessage ($script:localizedData.PostValidationError -f $setTargetResourceParameters.Path) ` + -ErrorTestName $script:errorMessageTitles.PostValidationError + } + + Mock -CommandName 'Get-MsiProductCode' -MockWith { return $script:testWrongProductId } + + Context 'Product code from downloaded MSI package does not match specified ID' { + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -ShouldThrow $true ` + -ErrorMessage ($script:localizedData.InvalidId -f $script:testIdentifyingNumber, $script:testWrongProductId) ` + -ErrorTestName $script:errorMessageTitles.InvalidId + } + + Mock -CommandName 'New-Object' -MockWith { Throw } + + Context 'Failure while creating the file stream object to download to' { + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -ShouldThrow $true ` + -ErrorMessage ($script:localizedData.CouldNotOpenDestFile -f $script:destinationPath) ` + -ErrorTestName $script:errorMessageTitles.CouldNotOpenDestFile + } + + Mock -CommandName 'Convert-PathToUri' -MockWith { return $script:testUriNonUnc } + Mock -CommandName 'Test-Path' -MockWith { return $false } -ParameterFilter { $Path -eq $script:testPath } + + Context 'Invalid path was passed in' { + Invoke-SetTargetResourceUnitTest -SetTargetResourceParameters $setTargetResourceParameters ` + -ShouldThrow $true ` + -ErrorMessage ($script:localizedData.PathDoesNotExist -f $script:testPath) ` + -ErrorTestName $script:errorMessageTitles.PathDoesNotExist + } + } + + Describe 'Test-TargetResource' { + Mock -CommandName 'Convert-ProductIdToIdentifyingNumber' -MockWith { return $script:testIdentifyingNumber } + Mock -CommandName 'Get-ProductEntry' -MockWith { return $script:mockProductEntry } + Mock -CommandName 'Get-ProductEntryValue' -MockWith { return $script:mockProductEntryInfo.Name } + + Context 'Specified package is present and should be' { + $testTargetResourceParameters = @{ + ProductId = $script:testProductId + Path = $script:testPath + Ensure = 'Present' + } + + $mocksCalled = @( + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Get-ProductEntry'; Times = 1 } + @{ Command = 'Get-ProductEntryValue'; Times = 1 } + ) + + Invoke-TestTargetResourceUnitTest -TestTargetResourceParameters $testTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ExpectedReturnValue $true + } + + Context 'Specified package is present but should not be' { + $testTargetResourceParameters = @{ + ProductId = $script:testProductId + Path = $script:testPath + Ensure = 'Absent' + } + + $mocksCalled = @( + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Get-ProductEntry'; Times = 1 } + @{ Command = 'Get-ProductEntryValue'; Times = 1 } + ) + + Invoke-TestTargetResourceUnitTest -TestTargetResourceParameters $testTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ExpectedReturnValue $false + } + + Mock -CommandName 'Get-ProductEntry' -MockWith { return $null } + + Context 'Specified package is Absent but should not be' { + $testTargetResourceParameters = @{ + ProductId = $script:testProductId + Path = $script:testPath + Ensure = 'Present' + } + + $mocksCalled = @( + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Get-ProductEntry'; Times = 1 } + @{ Command = 'Get-ProductEntryValue'; Times = 0 } + ) + + Invoke-TestTargetResourceUnitTest -TestTargetResourceParameters $testTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ExpectedReturnValue $false + } + + Context 'Specified package is Absent and should be' { + $testTargetResourceParameters = @{ + ProductId = $script:testProductId + Path = $script:testPath + Ensure = 'Absent' + } + + $mocksCalled = @( + @{ Command = 'Convert-ProductIdToIdentifyingNumber'; Times = 1 } + @{ Command = 'Get-ProductEntry'; Times = 1 } + @{ Command = 'Get-ProductEntryValue'; Times = 0 } + ) + + Invoke-TestTargetResourceUnitTest -TestTargetResourceParameters $testTargetResourceParameters ` + -MocksCalled $mocksCalled ` + -ExpectedReturnValue $true + } + } + + Describe 'Assert-PathExtensionValid' { + Context 'Path is a valid .msi path' { + It 'Should not throw' { + { Assert-PathExtensionValid -Path 'testMsiFile.msi' } | Should Not Throw + } + } + + Context 'Path is not a valid .msi path' { + It 'Should throw an invalid argument exception when an EXE file is passed in' { + $invalidPath = 'testMsiFile.exe' + $expectedErrorMessage = ($script:localizedData.InvalidBinaryType -f $invalidPath) + + { Assert-PathExtensionValid -Path $invalidPath } | Should Throw $expectedErrorMessage + } + + It 'Should throw an invalid argument exception when an invalid file type is passed in' { + $invalidPath = 'testMsiFilemsi' + $expectedErrorMessage = ($script:localizedData.InvalidBinaryType -f $invalidPath) + + { Assert-PathExtensionValid -Path $invalidPath } | Should Throw $expectedErrorMessage + } + } + } + + Describe 'Convert-PathToUri' { + Context 'Path has a valid URI scheme' { + It 'Should return the expected URI when scheme is a file' { + $filePath = (Join-Path -Path $PSScriptRoot -ChildPath 'testMsi.msi') + $expectedReturnValue = [Uri] $filePath + + Convert-PathToUri -Path $filePath | Should Be $expectedReturnValue + } + + It 'Should return the expected URI when scheme is http' { + $filePath = 'http://localhost:1242/testMsi.msi' + $expectedReturnValue = [Uri] $filePath + + Convert-PathToUri -Path $filePath | Should Be $expectedReturnValue + } + + It 'Should return the expected URI when scheme is https' { + $filePath = 'https://localhost:1243/testMsi.msi' + $expectedReturnValue = [Uri] $filePath + + Convert-PathToUri -Path $filePath | Should Be $expectedReturnValue + } + } + + Context 'Invalid path passed in' { + It 'Should throw an error when uri scheme is invalid' { + $filePath = 'ht://localhost:1243/testMsi.msi' + $expectedErrorMessage = ($script:localizedData.InvalidPath -f $filePath) + + { Convert-PathToUri -Path $filePath } | Should Throw $expectedErrorMessage + } + + It 'Should throw an error when path is not in valid format' { + $filePath = 'mri' + $expectedErrorMessage = ($script:localizedData.InvalidPath -f $filePath) + + { Convert-PathToUri -Path $filePath } | Should Throw $expectedErrorMessage + } + } + } + + Describe 'Convert-ProductIdToIdentifyingNumber' { + Context 'Valid Product ID is passed in' { + It 'Should return the same value that is passed in when the Product ID is already in the correct format' { + Convert-ProductIdToIdentifyingNumber -ProductId $script:testIdentifyingNumber | Should Be $script:testIdentifyingNumber + } + + It 'Should convert a valid poduct ID to the identifying number format' { + Convert-ProductIdToIdentifyingNumber -ProductId $script:testProductId | Should Be $script:testIdentifyingNumber + } + } + + Context 'Invalid Product ID is passed in' { + It 'Should throw an exception when an invalid product ID is passed in' { + $expectedErrorMessage = ($script:localizedData.InvalidIdentifyingNumber -f $script:testWrongProductId) + { Convert-ProductIdToIdentifyingNumber -ProductId $script:testWrongProductId } | Should Throw $expectedErrorMessage + } + } + } + + Describe 'Get-ProductEntry' { + $uninstallRegistryKeyLocation = (Join-Path -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' -ChildPath $script:testIdentifyingNumber) + $uninstallRegistryKeyWow64Location = (Join-Path -Path 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' -ChildPath $script:testIdentifyingNumber) + + Mock -CommandName 'Get-Item' -MockWith { return $script:mockProductEntry } -ParameterFilter { $Path -eq $uninstallRegistryKeyLocation } + Mock -CommandName 'Get-Item' -MockWith { return $script:mockProductEntry } -ParameterFilter { $Path -eq $uninstallRegistryKeyWow64Location } + + Context 'Product entry is found in the expected location' { + It 'Should return the expected product entry' { + Get-ProductEntry -IdentifyingNumber $script:testIdentifyingNumber | Should Be $script:mockProductEntry + } + + It 'Should retrieve the item' { + Assert-MockCalled -CommandName 'Get-Item' -Exactly 1 -Scope 'Context' + } + } + + Mock -CommandName 'Get-Item' -MockWith { return $null } -ParameterFilter { $Path -eq $uninstallRegistryKeyLocation } + + Context 'Product entry is found under Wow6432Node' { + It 'Should return the expected product entry' { + Get-ProductEntry -IdentifyingNumber $script:testIdentifyingNumber | Should Be $script:mockProductEntry + } + + It 'Should attempt to retrieve the item twice' { + Assert-MockCalled -CommandName 'Get-Item' -Exactly 2 -Scope 'Context' + } + } + + Mock -CommandName 'Get-Item' -MockWith { return $null } -ParameterFilter { $Path -eq $uninstallRegistryKeyWow64Location } + + Context 'Product entry is not found' { + It 'Should return $null' { + Get-ProductEntry -IdentifyingNumber $script:testIdentifyingNumber | Should Be $null + } + + It 'Should attempt to retrieve the item twice' { + Assert-MockCalled -CommandName 'Get-Item' -Exactly 2 -Scope 'Context' + } + } + } + + Describe 'Get-ProductEntryInfo' { + Mock -CommandName Get-ProductEntryValue -MockWith { return '20170404' } -ParameterFilter { $Property -eq 'InstallDate' } + Mock -CommandName Get-ProductEntryValue -MockWith { return $script:mockProductEntryInfo.Publisher } -ParameterFilter { $Property -eq 'Publisher' } + Mock -CommandName Get-ProductEntryValue -MockWith { return $script:mockProductEntryInfo.Size } -ParameterFilter { $Property -eq 'EstimatedSize' } + Mock -CommandName Get-ProductEntryValue -MockWith { return $script:mockProductEntryInfo.Version } -ParameterFilter { $Property -eq 'DisplayVersion' } + Mock -CommandName Get-ProductEntryValue -MockWith { return $script:mockProductEntryInfo.PackageDescription } -ParameterFilter { $Property -eq 'Comments' } + Mock -CommandName Get-ProductEntryValue -MockWith { return $script:mockProductEntryInfo.Name } -ParameterFilter { $Property -eq 'DisplayName' } + Mock -CommandName Get-ProductEntryValue -MockWith { return $script:mockProductEntryInfo.InstallSource } -ParameterFilter { $Property -eq 'InstallSource' } + + Context 'All properties are retrieved successfully' { + + $getProductEntryInfoResult = Get-ProductEntryInfo -ProductEntry $script:mockProductEntry + + It 'Should return the expected installed date' { + $getProductEntryInfoResult.InstalledOn | Should Be $script:mockProductEntryInfo.InstalledOn + } + + It 'Should return the expected publisher' { + $getProductEntryInfoResult.Publisher | Should Be $script:mockProductEntryInfo.Publisher + } + + It 'Should return the expected size' { + $getProductEntryInfoResult.Size | Should Be ($script:mockProductEntryInfo.Size / 1024) + } + + It 'Should return the expected Version' { + $getProductEntryInfoResult.Version | Should Be $script:mockProductEntryInfo.Version + } + + It 'Should return the expected package description' { + $getProductEntryInfoResult.PackageDescription | Should Be $script:mockProductEntryInfo.PackageDescription + } + + It 'Should return the expected name' { + $getProductEntryInfoResult.Name | Should Be $script:mockProductEntryInfo.Name + } + + It 'Should return the expected install source' { + $getProductEntryInfoResult.InstallSource | Should Be $script:mockProductEntryInfo.InstallSource + } + + It 'Should retrieve 7 product entry values' { + Assert-MockCalled -CommandName 'Get-ProductEntryValue' -Exactly 7 -Scope 'Context' + } + } + + Mock -CommandName Get-ProductEntryValue -MockWith { return '4/4/2017' } -ParameterFilter { $Property -eq 'InstallDate' } + + Context 'Install date is in incorrect format' { + + $getProductEntryInfoResult = Get-ProductEntryInfo -ProductEntry $script:mockProductEntry + + It 'Should return $null for InstalledOn' { + $getProductEntryInfoResult.InstalledOn | Should Be $null + } + } + } + + Describe 'New-LogFile' { + Mock -CommandName 'Test-Path' -MockWith { return $true } + Mock -CommandName 'Remove-Item' -MockWith {} + Mock -CommandName 'New-Item' -MockWith {} + + Context 'File with name of given log file already exists and creation of new log file succeeds' { + $mocksCalled = @( + @{ Command = 'Test-Path'; Times = 1; Custom = 'to the log file' } + @{ Command = 'Remove-Item'; Times = 1; Custom = 'any file with the same name as the log file' } + @{ Command = 'New-Item'; Times = 1; Custom = 'log file' } + ) + + Invoke-GenericUnitTest -Function { Param($script:testPath) New-LogFile $script:testPath } ` + -FunctionParameters @{ LogPath = $script:testPath } ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + Mock -CommandName 'Test-Path' -MockWith { return $false } + + Context 'File with name of given log file does not exist and creation of new log file succeeds' { + $mocksCalled = @( + @{ Command = 'Test-Path'; Times = 1; Custom = 'to the log file' } + @{ Command = 'Remove-Item'; Times = 0; Custom = 'any file with the same name as the log file' } + @{ Command = 'New-Item'; Times = 1; Custom = 'log file' } + ) + + Invoke-GenericUnitTest -Function { Param($script:testPath) New-LogFile $script:testPath } ` + -FunctionParameters @{ LogPath = $script:testPath } ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + Mock -CommandName 'New-Item' -MockWith { Throw } + + Context 'Creation of new log file fails' { + Invoke-GenericUnitTest -Function { Param($script:testPath) New-LogFile $script:testPath } ` + -FunctionParameters @{ LogPath = $script:testPath } ` + -ShouldThrow $true ` + -ErrorMessage ($script:localizedData.CouldNotOpenLog -f $script:testPath) ` + -ErrorTestName $script:errorMessageTitles.CouldNotOpenLog + } + } + + Describe 'Get-WebRequestResponse' { + Mock -CommandName 'Get-WebRequest' -MockWith { return $script:mockWebRequest } + Mock -CommandName 'Get-ScriptBlock' -MockWith { return { Write-Verbose 'Hello World' } } + Mock -CommandName 'Get-WebRequestResponseStream' -MockWith { return $script:mockStream } + + Context 'URI scheme is Http and response is successfully retrieved' { + $mocksCalled = @( + @{ Command = 'Get-WebRequest'; Times = 1 } + @{ Command = 'Get-ScriptBlock'; Times = 0 } + @{ Command = 'Get-WebRequestResponseStream'; Times = 1 } + ) + + It 'Should return the expected response stream' { + Get-WebRequestResponse -Uri $script:testUriHttp | Should Be $script:mockStream + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $mocksCalled + } + + Context 'URI scheme is Https with no callback and response is successfully retrieved' { + $mocksCalled = @( + @{ Command = 'Get-WebRequest'; Times = 1 } + @{ Command = 'Get-ScriptBlock'; Times = 0 } + @{ Command = 'Get-WebRequestResponseStream'; Times = 1 } + ) + + It 'Should return the expected response stream' { + Get-WebRequestResponse -Uri $script:testUriHttps | Should Be $script:mockStream + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $mocksCalled + } + + Context 'URI scheme is Https with callback and response is successfully retrieved' { + $mocksCalled = @( + @{ Command = 'Get-WebRequest'; Times = 1 } + @{ Command = 'Get-ScriptBlock'; Times = 1 } + @{ Command = 'Get-WebRequestResponseStream'; Times = 1 } + ) + + It 'Should return the expected response stream' { + Get-WebRequestResponse -Uri $script:testUriHttps -ServerCertificateValidationCallback 'TestCallbackFunction' | Should Be $script:mockStream + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $mocksCalled + } + + Mock -CommandName 'Get-WebRequestResponseStream' -MockWith { Throw } + + Context 'Error occurred during while retrieving the response' { + It 'Should throw the expected exception' { + $expectedErrorMessage = ($script:localizedData.CouldNotGetResponseFromWebRequest -f $script:testUriHttp.Scheme, $script:testUriHttp.OriginalString) + { Get-WebRequestResponse -Uri $script:testUriHttp } | Should Throw $expectedErrorMessage + } + } + } + + Describe 'Assert-FileValid' { + Mock -CommandName 'Assert-FileHashValid' -MockWith {} + Mock -CommandName 'Assert-FileSignatureValid' -MockWith {} + + Context 'FileHash is passed in and SignerThumbprint and SignerSubject are not' { + $mocksCalled = @( + @{ Command = 'Assert-FileHashValid'; Times = 1 } + @{ Command = 'Assert-FileSignatureValid'; Times = 0 } + ) + + It 'Should not throw' { + { Assert-FileValid -Path $script:testPath -FileHash 'mockFileHash' } | Should Not Throw + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $mocksCalled + } + + Context 'FileHash and SignerThumbprint are passed in but SignerSubject is not' { + $mocksCalled = @( + @{ Command = 'Assert-FileHashValid'; Times = 1 } + @{ Command = 'Assert-FileSignatureValid'; Times = 1 } + ) + + It 'Should not throw' { + { Assert-FileValid -Path $script:testPath -FileHash 'mockFileHash' -SignerThumbprint 'mockSignerThumbprint' } | Should Not Throw + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $mocksCalled + } + + Context 'Only Path and SignerSubject are passed in' { + $mocksCalled = @( + @{ Command = 'Assert-FileHashValid'; Times = 0 } + @{ Command = 'Assert-FileSignatureValid'; Times = 1 } + ) + + It 'Should not throw' { + { Assert-FileValid -Path $script:testPath -SignerSubject 'mockSignerSubject' } | Should Not Throw + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $mocksCalled + } + + Context 'FileHash, SignerThumbprint, and SignerSubject are passed in' { + $mocksCalled = @( + @{ Command = 'Assert-FileHashValid'; Times = 1 } + @{ Command = 'Assert-FileSignatureValid'; Times = 1 } + ) + + It 'Should not throw' { + { Assert-FileValid -Path $script:testPath -FileHash 'mockFileHash' ` + -SignerThumbprint 'mockSignerThumbprint' ` + -SignerSubject 'mockSignerSubject' + } | Should Not Throw + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $mocksCalled + } + + Context 'SignerThumbprint and SignerSubject are passed in but FileHash is not' { + $mocksCalled = @( + @{ Command = 'Assert-FileHashValid'; Times = 0 } + @{ Command = 'Assert-FileSignatureValid'; Times = 1 } + ) + + It 'Should not throw' { + { Assert-FileValid -Path $script:testPath -SignerThumbprint 'mockSignerThumbprint' ` + -SignerSubject 'mockSignerSubject' + } | Should Not Throw + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $mocksCalled + } + + Context 'Only path is passed in' { + $mocksCalled = @( + @{ Command = 'Assert-FileHashValid'; Times = 0 } + @{ Command = 'Assert-FileSignatureValid'; Times = 0 } + ) + + It 'Should not throw' { + { Assert-FileValid -Path $script:testPath } | Should Not Throw + } + + Invoke-ExpectedMocksAreCalledTest -MocksCalled $mocksCalled + } + } + + Describe 'Assert-FileHashValid' { + $mockHash = @{ Hash = 'testHash' } + Mock -CommandName 'Get-FileHash' -MockWith { return $mockHash } + + Context 'File hash is valid' { + It 'Should not throw when hashes match' { + { Assert-FileHashValid -Path $script:testPath -Hash $mockHash.Hash -Algorithm 'SHA256' } | Should Not Throw + } + + It 'Should fetch the file hash' { + Assert-MockCalled -CommandName 'Get-FileHash' -Exactly 1 -Scope 'Context' + } + } + + Context 'File hash is invalid' { + $badHash = 'BadHash' + $expectedErrorMessage = ($script:localizedData.InvalidFileHash -f $script:testPath, $badHash, 'SHA256') + + It 'Should throw when hashes do not match' { + { Assert-FileHashValid -Path $script:testPath -Hash $badHash -Algorithm 'SHA256' } | Should Throw $expectedErrorMessage + } + } + } + + Describe 'Assert-FileSignatureValid' { + $mockThumbprint = 'mockThumbprint' + $mockSubject = 'mockSubject' + $mockSignature = @{ + Status = [System.Management.Automation.SignatureStatus]::Valid + SignerCertificate = @{ Thumbprint = $mockThumbprint; Subject = $mockSubject } + } + + Mock -CommandName 'Get-AuthenticodeSignature' -MockWith { return $mockSignature } + + Context 'File signature status, thumbprint and subject are valid' { + It 'Should not throw' { + { Assert-FileSignatureValid -Path $script:testPath -Thumbprint $mockThumbprint -Subject $mockSubject } | Should Not Throw + } + } + + Context 'File signature status and thumbprint are valid and Subject not passed in' { + It 'Should not throw' { + { Assert-FileSignatureValid -Path $script:testPath -Thumbprint $mockThumbprint } | Should Not Throw + } + } + + Context 'File signature status and subject are valid and Thumbprint not passed in' { + It 'Should not throw' { + { Assert-FileSignatureValid -Path $script:testPath -Subject $mockSubject } | Should Not Throw + } + } + + Context 'Only Path is passed in' { + It 'Should not throw' { + { Assert-FileSignatureValid -Path $script:testPath } | Should Not Throw + } + } + + Context 'File signature status and thumbprint are valid and subject is invalid' { + $badSubject = 'BadSubject' + $expectedErrorMessage = ($script:localizedData.WrongSignerSubject -f $script:testPath, $badSubject) + + It 'Should throw expected error message' { + { Assert-FileSignatureValid -Path $script:testPath -Thumbprint $mockThumbprint -Subject $badSubject } | Should Throw $expectedErrorMessage + } + } + + Context 'File signature status and subject are valid and thumbprint is invalid' { + $badThumbprint = 'BadThumbprint' + $expectedErrorMessage = ($script:localizedData.WrongSignerThumbprint -f $script:testPath, $badThumbprint) + + It 'Should throw expected error message' { + { Assert-FileSignatureValid -Path $script:testPath -Thumbprint $badThumbprint -Subject $mockSubject } | Should Throw $expectedErrorMessage + } + } + + Context 'File signature status is invalid and subject and thumbprint are valid' { + $mockSignature.Status = 'Invalid' + $expectedErrorMessage = ($script:localizedData.InvalidFileSignature -f $script:testPath, $mockSignature.Status) + + It 'Should throw expected error message' { + { Assert-FileSignatureValid -Path $script:testPath -Thumbprint $mockThumbprint -Subject $mockSubject } | Should Throw $expectedErrorMessage + } + } + } + + Describe 'Start-MsiProcess' { + Mock -CommandName 'New-Object' -MockWith { return $script:mockStartInfo } -ParameterFilter { $TypeName -eq 'System.Diagnostics.ProcessStartInfo' } + Mock -CommandName 'New-Object' -MockWith { return $script:mockProcess } -ParameterFilter { $TypeName -eq 'System.Diagnostics.Process' } + Mock -CommandName 'Get-ProductEntry' -MockWith { return $script:mockProductEntryInfo } + Mock -CommandName 'Invoke-PInvoke' -MockWith { return 0 } + Mock -CommandName 'Invoke-Process' -MockWith { return 0 } + + $startMsiProcessParameters = @{ + IdentifyingNumber = $script:testIdentifyingNumber + Path = $script:testPath + Ensure = 'Present' + Arguments = 'TestArguments' + LogPath = 'TestLogPath' + RunAsCredential = $script:testCredential + } + + Context 'Install MSI package with RunAsCredential specified' { + $mocksCalled = @( + @{ Command = 'New-Object'; Times = 1; Custom = 'process start info object' } + @{ Command = 'Get-ProductEntry'; Times = 0 } + @{ Command = 'Invoke-PInvoke'; Times = 1; Custom = 'install' } + @{ Command = 'Invoke-Process'; Times = 0; Custom = 'install' } + ) + + Invoke-GenericUnitTest -Function { Param($startMsiProcessParameters) Start-MsiProcess @startMsiProcessParameters } ` + -FunctionParameters $startMsiProcessParameters ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + $startMsiProcessParameters.Ensure = 'Absent' + + Context 'Uninstall MSI package with RunAsCredential specified' { + $mocksCalled = @( + @{ Command = 'New-Object'; Times = 1; Custom = 'process start info object' } + @{ Command = 'Get-ProductEntry'; Times = 1 } + @{ Command = 'Invoke-PInvoke'; Times = 1; Custom = 'uninstall' } + @{ Command = 'Invoke-Process'; Times = 0; Custom = 'uninstall' } + ) + + Invoke-GenericUnitTest -Function { Param($startMsiProcessParameters) Start-MsiProcess @startMsiProcessParameters } ` + -FunctionParameters $startMsiProcessParameters ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + $startMsiProcessParameters.Ensure = 'Present' + $startMsiProcessParameters.Remove('RunAsCredential') + + Context 'Install MSI package without RunAsCredential' { + $mocksCalled = @( + @{ Command = 'New-Object'; Times = 2; Custom = 'process start info object and process' } + @{ Command = 'Get-ProductEntry'; Times = 0 } + @{ Command = 'Invoke-PInvoke'; Times = 0; Custom = 'install' } + @{ Command = 'Invoke-Process'; Times = 1; Custom = 'install' } + ) + + Invoke-GenericUnitTest -Function { Param($startMsiProcessParameters) Start-MsiProcess @startMsiProcessParameters } ` + -FunctionParameters $startMsiProcessParameters ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + $startMsiProcessParameters.Ensure = 'Absent' + + Context 'Uninstall MSI package without RunAsCredential' { + $mocksCalled = @( + @{ Command = 'New-Object'; Times = 2; Custom = 'process start info object and process object' } + @{ Command = 'Get-ProductEntry'; Times = 1 } + @{ Command = 'Invoke-PInvoke'; Times = 0; Custom = 'uninstall' } + @{ Command = 'Invoke-Process'; Times = 1; Custom = 'uninstall' } + ) + + Invoke-GenericUnitTest -Function { Param($startMsiProcessParameters) Start-MsiProcess @startMsiProcessParameters } ` + -FunctionParameters $startMsiProcessParameters ` + -MocksCalled $mocksCalled ` + -ShouldThrow $false + } + + Mock -CommandName 'Invoke-Process' -MockWith { Throw } + + Context 'Error occurred while trying to invoke the process' { + Invoke-GenericUnitTest -Function { Param($startMsiProcessParameters) Start-MsiProcess @startMsiProcessParameters } ` + -FunctionParameters $startMsiProcessParameters ` + -ShouldThrow $true ` + -ErrorMessage ($script:localizedData.CouldNotStartProcess -f $script:testPath) ` + -ErrorTestName $script:errorMessageTitles.CouldNotStartProcess + } + } + } +}