diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fc9acd --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +DSCResource.Tests \ No newline at end of file diff --git a/DscResources/CommonResourceHelper.psm1 b/DscResources/CommonResourceHelper.psm1 new file mode 100644 index 0000000..2d684b7 --- /dev/null +++ b/DscResources/CommonResourceHelper.psm1 @@ -0,0 +1,142 @@ +<# + .SYNOPSIS + Tests if the current machine is a Nano server. +#> +function Test-IsNanoServer +{ + [OutputType([Boolean])] + [CmdletBinding()] + param () + + return $PSVersionTable.PSEdition -ieq 'Core' +} + +<# + .SYNOPSIS + Creates and throws an invalid argument exception + + .PARAMETER Message + The message explaining why this error is being thrown + + .PARAMETER ArgumentName + The name of the invalid argument that is causing this error to be thrown +#> +function New-InvalidArgumentException +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Message, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $ArgumentName + ) + + $argumentException = New-Object -TypeName 'ArgumentException' -ArgumentList @( $Message, + $ArgumentName ) + $newObjectParams = @{ + TypeName = 'System.Management.Automation.ErrorRecord' + ArgumentList = @( $argumentException, $ArgumentName, 'InvalidArgument', $null ) + } + $errorRecord = New-Object @newObjectParams + + throw $errorRecord +} + +<# + .SYNOPSIS + Creates and throws an invalid operation exception + + .PARAMETER Message + The message explaining why this error is being thrown + + .PARAMETER ErrorRecord + The error record containing the exception that is causing this terminating error +#> +function New-InvalidOperationException +{ + [CmdletBinding()] + param + ( + [ValidateNotNullOrEmpty()] + [String] + $Message, + + [ValidateNotNull()] + [System.Management.Automation.ErrorRecord] + $ErrorRecord + ) + + if ($null -eq $Message) + { + $invalidOperationException = New-Object -TypeName 'InvalidOperationException' + } + elseif ($null -eq $ErrorRecord) + { + $invalidOperationException = + New-Object -TypeName 'InvalidOperationException' -ArgumentList @( $Message ) + } + else + { + $invalidOperationException = + New-Object -TypeName 'InvalidOperationException' -ArgumentList @( $Message, + $ErrorRecord.Exception ) + } + + $newObjectParams = @{ + TypeName = 'System.Management.Automation.ErrorRecord' + ArgumentList = @( $invalidOperationException.ToString(), 'MachineStateIncorrect', + 'InvalidOperation', $null ) + } + $errorRecordToThrow = New-Object @newObjectParams + throw $errorRecordToThrow +} + +<# + .SYNOPSIS + Retrieves the localized string data based on the machine's culture. + Falls back to en-US strings if the machine's culture is not supported. + + .PARAMETER ResourceName + The name of the resource as it appears before '.strings.psd1' of the localized string file. + + For example: + For WindowsOptionalFeature: MSFT_xWindowsOptionalFeature + For Service: MSFT_xServiceResource + For Registry: MSFT_xRegistryResource +#> +function Get-LocalizedData +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $ResourceName + ) + + $resourceDirectory = (Join-Path -Path $PSScriptRoot -ChildPath $ResourceName) + $localizedStringFileLocation = Join-Path -Path $resourceDirectory -ChildPath $PSUICulture + + if (-not (Test-Path -Path $localizedStringFileLocation)) + { + # Fallback to en-US + $localizedStringFileLocation = Join-Path -Path $resourceDirectory -ChildPath 'en-US' + } + + Import-LocalizedData ` + -BindingVariable 'localizedData' ` + -FileName "$ResourceName.strings.psd1" ` + -BaseDirectory $localizedStringFileLocation + + return $localizedData +} + +Export-ModuleMember -Function @( 'Test-IsNanoServer', 'New-InvalidArgumentException', + 'New-InvalidOperationException', 'Get-LocalizedData' ) diff --git a/DscResources/MSFT_WindowsOptionalFeature/MSFT_WindowsOptionalFeature.psm1 b/DscResources/MSFT_WindowsOptionalFeature/MSFT_WindowsOptionalFeature.psm1 new file mode 100644 index 0000000..992778a --- /dev/null +++ b/DscResources/MSFT_WindowsOptionalFeature/MSFT_WindowsOptionalFeature.psm1 @@ -0,0 +1,385 @@ +# PSSA global rule suppression is allowed here because $global:DSCMachineStatus must be set +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')] +param () + +Import-Module -Name (Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'CommonResourceHelper.psm1') +$script:localizedData = Get-LocalizedData -ResourceName 'MSFT_WindowsOptionalFeature' + +<# + .SYNOPSIS + Retrieves the state of a Windows optional feature resource. + + .PARAMETER Name + The name of the Windows optional feature resource to retrieve. +#> +function Get-TargetResource +{ + [CmdletBinding()] + [OutputType([Hashtable])] + param + ( + [Parameter(Mandatory = $true)] + [String] + $Name + ) + + Write-Verbose -Message ($LocalizedData.GetTargetResourceStartMessage -f $Name) + + Assert-ResourcePrerequisitesValid + + $windowsOptionalFeature = Dism\Get-WindowsOptionalFeature -FeatureName $Name -Online + + <# + $windowsOptionalFeatureProperties and this section of code are needed because an error will be thrown if a property + is not found in WMF 4 instead of returning null. + #> + $windowsOptionalFeatureProperties = @{} + $propertiesNeeded = @( 'LogPath', 'State', 'CustomProperties', 'FeatureName', 'LogLevel', 'Description', 'DisplayName' ) + + foreach ($property in $propertiesNeeded) + { + try + { + $windowsOptionalFeatureProperties[$property] = $windowsOptionalFeature.$property + } + catch + { + $windowsOptionalFeatureProperties[$property] = $null + } + } + + $windowsOptionalFeatureResource = @{ + LogPath = $windowsOptionalFeatureProperties.LogPath + Ensure = Convert-FeatureStateToEnsure -State $windowsOptionalFeatureProperties.State + CustomProperties = + Convert-CustomPropertyArrayToStringArray -CustomProperties $windowsOptionalFeatureProperties.CustomProperties + Name = $windowsOptionalFeatureProperties.FeatureName + LogLevel = $windowsOptionalFeatureProperties.LogLevel + Description = $windowsOptionalFeatureProperties.Description + DisplayName = $windowsOptionalFeatureProperties.DisplayName + } + + Write-Verbose -Message ($script:localizedData.GetTargetResourceEndMessage -f $Name) + + return $windowsOptionalFeatureResource +} + +<# + .SYNOPSIS + Enables or disables a Windows optional feature + + .PARAMETER Name + The name of the feature to enable or disable. + + .PARAMETER Ensure + Specifies whether the feature should be enabled or disabled. + To enable the feature, set this property to Present. + To disable the feature, set the property to Absent. + + .PARAMETER RemoveFilesOnDisable + Specifies that all files associated with the feature should be removed if the feature is + being disabled. + + .PARAMETER NoWindowsUpdateCheck + Specifies whether or not DISM contacts Windows Update (WU) when searching for the source + files to enable the feature. + If $true, DISM will not contact WU. + + .PARAMETER LogPath + The path to the log file to log this operation. + There is no default value, but if not set, the log will appear at + %WINDIR%\Logs\Dism\dism.log. + + .PARAMETER LogLevel + The maximum output level to show in the log. + Accepted values are: "ErrorsOnly" (only errors are logged), "ErrorsAndWarning" (errors and + warnings are logged), and "ErrorsAndWarningAndInformation" (errors, warnings, and debug + information are logged). +#> +function Set-TargetResource +{ + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter(Mandatory = $true)] + [String] + $Name, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [Boolean] + $RemoveFilesOnDisable, + + [Boolean] + $NoWindowsUpdateCheck, + + [String] + $LogPath, + + [ValidateSet('ErrorsOnly', 'ErrorsAndWarning', 'ErrorsAndWarningAndInformation')] + [String] + $LogLevel = 'ErrorsAndWarningAndInformation' + ) + + Write-Verbose -Message ($script:localizedData.SetTargetResourceStartMessage -f $Name) + + Assert-ResourcePrerequisitesValid + + switch ($LogLevel) + { + 'ErrorsOnly' { $dismLogLevel = 'Errors' } + 'ErrorsAndWarning' { $dismLogLevel = 'Warnings' } + 'ErrorsAndWarningAndInformation' { $dismLogLevel = 'WarningsInfo' } + } + + # Construct splatting hashtable for DISM cmdlets + $dismCmdletParameters = @{ + FeatureName = $Name + Online = $true + LogLevel = $dismLogLevel + NoRestart = $true + } + + if ($PSBoundParameters.ContainsKey('LogPath')) + { + $dismCmdletParameters['LogPath'] = $LogPath + } + + if ($Ensure -eq 'Present') + { + if ($PSCmdlet.ShouldProcess($Name, $script:localizedData.ShouldProcessEnableFeature)) + { + if ($NoWindowsUpdateCheck) + { + $dismCmdletParameters['LimitAccess'] = $true + } + + $windowsOptionalFeature = Dism\Enable-WindowsOptionalFeature @dismCmdletParameters + } + + Write-Verbose -Message ($script:localizedData.FeatureInstalled -f $Name) + } + else + { + if ($PSCmdlet.ShouldProcess($Name, $script:localizedData.ShouldProcessDisableFeature)) + { + if ($RemoveFilesOnDisable) + { + $dismCmdletParameters['Remove'] = $true + } + + $windowsOptionalFeature = Dism\Disable-WindowsOptionalFeature @dismCmdletParameters + } + + Write-Verbose -Message ($script:localizedData.FeatureUninstalled -f $Name) + } + + <# + $restartNeeded and this section of code are needed because an error will be thrown if the + RestartNeeded property is not found in WMF 4. + #> + try + { + $restartNeeded = $windowsOptionalFeature.RestartNeeded + } + catch + { + $restartNeeded = $false + } + + # Indicate we need a restart if needed + if ($restartNeeded) + { + Write-Verbose -Message $script:localizedData.RestartNeeded + $global:DSCMachineStatus = 1 + } + + Write-Verbose -Message ($script:localizedData.SetTargetResourceEndMessage -f $Name) +} + +<# + .SYNOPSIS + Tests if a Windows optional feature is in the specified state. + + .PARAMETER Name + The name of the feature to test the state of. + + .PARAMETER Ensure + Specifies whether the feature should be enabled or disabled. + To test if the feature is enabled, set this property to Present. + To test if the feature is disabled, set this property to Absent. + + .PARAMETER RemoveFilesOnDisable + Not used in Test-TargetResource. + + .PARAMETER NoWindowsUpdateCheck + Not used in Test-TargetResource. + + .PARAMETER LogPath + Not used in Test-TargetResource. + + .PARAMETER LogLevel + Not used in Test-TargetResource. +#> +function Test-TargetResource +{ + [CmdletBinding()] + [OutputType([Boolean])] + param + ( + [Parameter(Mandatory = $true)] + [String] + $Name, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [Boolean] + $RemoveFilesOnDisable, + + [Boolean] + $NoWindowsUpdateCheck, + + [String] + $LogPath, + + [ValidateSet('ErrorsOnly', 'ErrorsAndWarning', 'ErrorsAndWarningAndInformation')] + [String] + $LogLevel = 'ErrorsAndWarningAndInformation' + ) + + Write-Verbose -Message ($script:localizedData.TestTargetResourceStartMessage -f $Name) + + Assert-ResourcePrerequisitesValid + + $windowsOptionalFeature = Dism\Get-WindowsOptionalFeature -FeatureName $Name -Online + + $featureIsInDesiredState = $false + + if ($null -eq $windowsOptionalFeature -or $windowsOptionalFeature.State -eq 'Disabled') + { + $featureIsInDesiredState = $Ensure -eq 'Absent' + } + elseif ($windowsOptionalFeature.State -eq 'Enabled') + { + $featureIsInDesiredState = $Ensure -eq 'Present' + } + + Write-Verbose -Message ($script:localizedData.TestTargetResourceEndMessage -f $Name) + + return $featureIsInDesiredState +} + +<# + .SYNOPSIS + Converts a list of CustomProperty objects into an array of Strings. + + .PARAMETER CustomProperties + The list of CustomProperty objects to be converted. + Each CustomProperty object should have Name, Value, and Path properties. +#> +function Convert-CustomPropertyArrayToStringArray +{ + [CmdletBinding()] + [OutputType([String[]])] + param + ( + [PSCustomObject[]] + $CustomProperties + ) + + $propertiesAsStrings = [String[]] @() + + foreach ($customProperty in $CustomProperties) + { + if ($null -ne $customProperty) + { + $propertiesAsStrings += "Name = $($customProperty.Name), Value = $($customProperty.Value), Path = $($customProperty.Path)" + } + } + + return $propertiesAsStrings +} + +<# + .SYNOPSIS + Converts the string state returned by the DISM Get-WindowsOptionalFeature cmdlet to Present or Absent. + + .PARAMETER State + The state to be converted to either Present or Absent. + Should be either Enabled or Disabled. +#> +function Convert-FeatureStateToEnsure +{ + [CmdletBinding()] + [OutputType([String])] + param + ( + [Parameter(Mandatory = $true)] + [String] + $State + ) + + if ($State -eq 'Disabled') + { + return 'Absent' + } + elseif ($State -eq 'Enabled') + { + return 'Present' + } + else + { + Write-Warning ($script:localizedData.CouldNotConvertFeatureState -f $State) + return $State + } +} + +<# + .SYNOPSIS + Throws errors if the prerequisites for using WindowsOptionalFeature are not met on the + target machine. + + Current prerequisites are: + - Must be running either a Windows client, at least Windows Server 2012, or Nano Server + - Must be running as an administrator + - The DISM PowerShell module must be available for import +#> +function Assert-ResourcePrerequisitesValid +{ + [CmdletBinding()] + param () + + Write-Verbose -Message $script:localizedData.ValidatingPrerequisites + + # Check that we're running on Server 2012 (or later) or on a client SKU + $operatingSystem = Get-CimInstance -ClassName 'Win32_OperatingSystem' + + if (($operatingSystem.ProductType -eq 2) -and ([System.Int32] $operatingSystem.BuildNumber -lt 9600)) + { + New-InvalidOperationException -Message $script:localizedData.NotSupportedSku + } + + # Check that we are running as an administrator + $windowsIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $windowsPrincipal = New-Object -TypeName 'System.Security.Principal.WindowsPrincipal' -ArgumentList @( $windowsIdentity ) + + $adminRole = [System.Security.Principal.WindowsBuiltInRole]::Administrator + if (-not $windowsPrincipal.IsInRole($adminRole)) + { + New-InvalidOperationException -Message $script:localizedData.ElevationRequired + } + + # Check that Dism PowerShell module is available + Import-Module -Name 'Dism' -ErrorVariable 'errorsFromDismImport' -ErrorAction 'SilentlyContinue' -Force + + if ($errorsFromDismImport.Count -gt 0) + { + New-InvalidOperationException -Message $script:localizedData.DismNotAvailable + } +} + +Export-ModuleMember -Function *-TargetResource diff --git a/DscResources/MSFT_WindowsOptionalFeature/MSFT_WindowsOptionalFeature.schema.mof b/DscResources/MSFT_WindowsOptionalFeature/MSFT_WindowsOptionalFeature.schema.mof new file mode 100644 index 0000000..15aefc2 Binary files /dev/null and b/DscResources/MSFT_WindowsOptionalFeature/MSFT_WindowsOptionalFeature.schema.mof differ diff --git a/DscResources/MSFT_WindowsOptionalFeature/en-US/MSFT_WindowsOptionalFeature.schema.mfl b/DscResources/MSFT_WindowsOptionalFeature/en-US/MSFT_WindowsOptionalFeature.schema.mfl new file mode 100644 index 0000000..ccd5508 Binary files /dev/null and b/DscResources/MSFT_WindowsOptionalFeature/en-US/MSFT_WindowsOptionalFeature.schema.mfl differ diff --git a/DscResources/MSFT_WindowsOptionalFeature/en-US/MSFT_WindowsOptionalFeature.strings.psd1 b/DscResources/MSFT_WindowsOptionalFeature/en-US/MSFT_WindowsOptionalFeature.strings.psd1 new file mode 100644 index 0000000..fda9d6d --- /dev/null +++ b/DscResources/MSFT_WindowsOptionalFeature/en-US/MSFT_WindowsOptionalFeature.strings.psd1 @@ -0,0 +1,20 @@ +# Localized resources for WindowsOptionalFeature + +ConvertFrom-StringData @' + DismNotAvailable = PowerShell module DISM could not be imported. + NotSupportedSku = This resource is available only on Windows client operating systems and Windows Server 2012 or later. + ElevationRequired = This resource must run as an Administrator. + ValidatingPrerequisites = Validating resource prerequisites... + CouldNotConvertFeatureState = Could not convert feature state '{0}' into Absent or Present. + RestartNeeded = Target machine needs to restart. + GetTargetResourceStartMessage = Started Get-TargetResource on the {0} feature. + GetTargetResourceEndMessage = Finished Get-TargetResource on the {0} feature. + SetTargetResourceStartMessage = Started Set-TargetResource on the {0} feature. + SetTargetResourceEndMessage = Finished Set-TargetResource on the {0} feature. + TestTargetResourceStartMessage = Started Test-TargetResource on the {0} feature. + TestTargetResourceEndMessage = Finished Test-TargetResource on the {0} feature. + FeatureInstalled = Installed feature {0}. + FeatureUninstalled = Uninstalled feature {0}. + ShouldProcessEnableFeature = Enable Windows optional feature + ShouldProcessDisableFeature = Disable Windows optional feature +'@ diff --git a/Examples/Sample_WindowsOptionalFeature.ps1 b/Examples/Sample_WindowsOptionalFeature.ps1 new file mode 100644 index 0000000..f2895d4 --- /dev/null +++ b/Examples/Sample_WindowsOptionalFeature.ps1 @@ -0,0 +1,38 @@ +<# + .SYNOPSIS + Enables the Windows optional feature with the specified name and outputs a log to the specified path. + + .PARAMETER FeatureName + The name of the Windows optional feature to enable. + + .PARAMETER LogPath + The path to the file to log the enable operation to. + + .NOTES + Can only be run on Windows client operating systems and Windows Server 2012 or later. + The DISM PowerShell module must be available on the target machine. +#> +Configuration Sample_WindowsOptionalFeature +{ + param + ( + [Parameter (Mandatory = $true)] + [String] + $FeatureName, + + [Parameter(Mandatory = $true)] + [String] + $LogPath + ) + + Import-DscResource -ModuleName 'PSDscResources' + + WindowsOptionalFeature TelnetClient + { + Name = $FeatureName + Ensure = 'Present' + LogPath = $LogPath + } +} + +Sample_WindowsOptionalFeature diff --git a/PSDscResources.psd1 b/PSDscResources.psd1 new file mode 100644 index 0000000..f472623 Binary files /dev/null and b/PSDscResources.psd1 differ diff --git a/README.md b/README.md index d696eb1..fb9d706 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ -# PSDscResources \ No newline at end of file +# PSDscResources + +PSDscResources is the new home of the in-box resources from PSDesiredStateCongfiguration. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Contributing +Please check out common DSC Resources [contributing guidelines](https://github.com/PowerShell/DscResource.Kit/blob/master/CONTRIBUTING.md). + +## Resources +* [WindowsOptionalFeature](#windows-optional-feature): Provides a mechanism to enable or disable optional features on a target node. + +### Resources that work on Nano Server + +* [WindowsOptionalFeature](#windows-optional-feature) + +### WindowsOptionalFeature +Provides a mechanism to enable or disable optional features on a target node. +This resource works on Nano Server. + +#### Requirements + +* Target machine must be running a Windows client operating system, Windows Server 2012 or later, or Nano Server. +* Target machine must have access to the DISM PowerShell module + +#### Parameters + +* **[String] Name** _(Key)_: The name of the Windows optional feature to enable or disable. +* **[String] Ensure** _(Write)_: Specifies whether the feature should be enabled or disabled. To enable the feature, set this property to Present. To disable the feature, set the property to Absent. The default value is Present. { *Present* | Absent }. +* **[Boolean] RemoveFilesOnDisable** _(Write)_: Specifies that all files associated with the feature should be removed if the feature is being disabled. +* **[Boolean] NoWindowsUpdateCheck** _(Write)_: Specifies whether or not DISM contacts Windows Update (WU) when searching for the source files to enable the feature. If $true, DISM will not contact WU. +* **[String] LogPath** _(Write)_: The path to the log file to log this operation. There is no default value, but if not set, the log will appear at %WINDIR%\Logs\Dism\dism.log. +* **[String] LogLevel** _(Write)_: The maximum output level to show in the log. ErrorsOnly will log only errors. ErrorsAndWarning will log only errors and warnings. ErrorsAndWarningAndInformation will log errors, warnings, and debug information). The default value is "ErrorsAndWarningAndInformation". { ErrorsOnly | ErrorsAndWarning | *ErrorsAndWarningAndInformation* }. + +#### Read-Only Properties from Get-TargetResource + +* **[String[]] CustomProperties** _(Read)_: The custom properties retrieved from the Windows optional feature as an array of strings. +* **[String] Description** _(Read)_: The description retrieved from the Windows optional feature. +* **[String] DisplayName** _(Read)_: The display name retrieved from the Windows optional feature. + +#### Examples + +* [Enable the specified windows optional feature and output logs to the specified path](https://github.com/PowerShell/PSDscResources/blob/master/Examples/Sample_WindowsOptionalFeature.ps1) + +### Unreleased + +### 2.0.0.0 + +* Initial release of PSDscResources \ No newline at end of file diff --git a/Tests/Integration/MSFT_WindowsOptionalFeature.Integration.Tests.ps1 b/Tests/Integration/MSFT_WindowsOptionalFeature.Integration.Tests.ps1 new file mode 100644 index 0000000..a2ee838 --- /dev/null +++ b/Tests/Integration/MSFT_WindowsOptionalFeature.Integration.Tests.ps1 @@ -0,0 +1,152 @@ +Import-Module -Name (Join-Path -Path (Join-Path -Path (Split-Path $PSScriptRoot -Parent) -ChildPath 'TestHelpers') -ChildPath 'CommonTestHelper.psm1') + +$script:testEnvironment = Enter-DscResourceTestEnvironment ` + -DscResourceModuleName 'PSDscResources' ` + -DscResourceName 'MSFT_WindowsOptionalFeature' ` + -TestType 'Integration' + +try +{ + Describe 'WindowsOptionalFeature Integration Tests' { + BeforeAll { + $script:enabledStates = @( 'Enabled', 'EnablePending' ) + $script:disabledStates = @( 'Disabled', 'DisablePending' ) + + $script:confgurationFilePath = Join-Path -Path $PSScriptRoot -ChildPath 'MSFT_WindowsOptionalFeature.config.ps1' + } + + It 'Should enable a valid Windows optional feature' { + $configurationName = 'EnableWindowsOptionalFeature' + + $resourceParameters = @{ + Name = 'TelnetClient' + Ensure = 'Present' + LogPath = Join-Path -Path $TestDrive -ChildPath 'EnableOptionalFeature.log' + NoWindowsUpdateCheck = $true + } + + $originalFeature = Dism\Get-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online + + try + { + if ($originalFeature.State -in $script:enabledStates) + { + Dism\Disable-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online -NoRestart + } + + { + . $script:confgurationFilePath -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @resourceParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + + $windowsOptionalFeature = Dism\Get-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online + + $windowsOptionalFeature | Should Not Be $null + $windowsOptionalFeature.State -in $script:enabledStates | Should Be $true + } + finally + { + if ($originalFeature.State -in $script:disabledStates) + { + Dism\Disable-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online -NoRestart + } + elseif ($originalFeature.State -in $script:enabledStates) + { + Dism\Enable-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online -NoRestart + } + + if (Test-Path -Path $resourceParameters.LogPath) + { + Remove-Item -Path $resourceParameters.LogPath -Recurse -Force + } + } + } + + It 'Should disable a valid Windows optional feature' { + $configurationName = 'DisableWindowsOptionalFeature' + + $resourceParameters = @{ + Name = 'TelnetClient' + Ensure = 'Absent' + LogPath = Join-Path -Path $TestDrive -ChildPath 'DisableOptionalFeature.log' + NoWindowsUpdateCheck = $true + RemoveFilesOnDisable = $false + } + + $originalFeature = Dism\Get-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online + + try + { + if ($originalFeature.State -in $script:disabledStates) + { + Dism\Enable-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online -NoRestart + } + + { + . $script:confgurationFilePath -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @resourceParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + + $windowsOptionalFeature = Dism\Get-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online + + $windowsOptionalFeature | Should Not Be $null + $windowsOptionalFeature.State -in $script:disabledStates | Should Be $true + } + finally + { + if ($originalFeature.State -in $script:disabledStates) + { + Dism\Disable-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online -NoRestart + } + elseif ($originalFeature.State -in $script:enabledStates) + { + Dism\Enable-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online -NoRestart + } + + if (Test-Path -Path $resourceParameters.LogPath) + { + Remove-Item -Path $resourceParameters.LogPath -Recurse -Force + } + } + } + + It 'Should not enable an incorrect Windows optional feature' { + $configurationName = 'EnableIncorrectWindowsOptionalFeature' + + $resourceParameters = @{ + Name = 'NonExistentWindowsOptionalFeature' + Ensure = 'Present' + LogPath = Join-Path -Path $TestDrive -ChildPath 'EnableIncorrectWindowsFeature.log' + NoWindowsUpdateCheck = $true + } + + Dism\Get-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online | Should Be $null + + try + { + { + . $script:confgurationFilePath -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @resourceParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Throw "Feature name $($resourceParameters.Name) is unknown." + + Test-Path -Path $resourceParameters.LogPath | Should Be $true + + Dism\Get-WindowsOptionalFeature -FeatureName $resourceParameters.Name -Online | Should Be $null + } + finally + { + if (Test-Path -Path $resourceParameters.LogPath) + { + Remove-Item -Path $resourceParameters.LogPath -Recurse -Force + } + } + } + } +} +finally +{ + Exit-DscResourceTestEnvironment -TestEnvironment $script:testEnvironment +} diff --git a/Tests/Integration/MSFT_WindowsOptionalFeature.config.ps1 b/Tests/Integration/MSFT_WindowsOptionalFeature.config.ps1 new file mode 100644 index 0000000..dd59b61 --- /dev/null +++ b/Tests/Integration/MSFT_WindowsOptionalFeature.config.ps1 @@ -0,0 +1,43 @@ +param +( + [Parameter(Mandatory = $true)] + [String] + $ConfigurationName +) + +Configuration $ConfigurationName +{ + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Name, + + [ValidateSet('Present', 'Absent')] + [ValidateNotNullOrEmpty()] + [String] + $Ensure = 'Present', + + [ValidateNotNullOrEmpty()] + [String] + $LogPath = (Join-Path -Path (Get-Location) -ChildPath 'WOFTestLog.txt'), + + [Boolean] + $RemoveFilesOnDisable = $false, + + [Boolean] + $NoWindowsUpdateCheck = $true + ) + + Import-DscResource -ModuleName 'PSDscResources' + + WindowsOptionalFeature WindowsOptionalFeature1 + { + Name = $Name + Ensure = $Ensure + LogPath = $LogPath + NoWindowsUpdateCheck = $NoWindowsUpdateCheck + RemoveFilesOnDisable = $RemoveFilesOnDisable + } +} diff --git a/Tests/TestHelpers/CommonTestHelper.psm1 b/Tests/TestHelpers/CommonTestHelper.psm1 new file mode 100644 index 0000000..b9220cc --- /dev/null +++ b/Tests/TestHelpers/CommonTestHelper.psm1 @@ -0,0 +1,738 @@ +Import-Module -Name (Join-Path -Path (Join-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -ChildPath 'DSCResources') -ChildPath 'CommonResourceHelper.psm1') + +<# + .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. + Uses Pester. + + .PARAMETER GetTargetResourceResult + The result of the Get-TargetResource method. + + .PARAMETER GetTargetResourceResultProperties + The properties that the result of Get-TargetResource should have. +#> +function Test-GetTargetResourceResult +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [Hashtable] $GetTargetResourceResult, + + [String[]] $GetTargetResourceResultProperties + ) + + foreach ($property in $GetTargetResourceResultProperties) + { + $GetTargetResourceResult[$property] | Should Not Be $null + } +} + +<# + .SYNOPSIS + Tests if a scope represents the current machine. + + .PARAMETER Scope + The scope to test. +#> +function Test-IsLocalMachine +{ + [OutputType([Boolean])] + param ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Scope + ) + + Set-StrictMode -Version latest + + if ($scope -eq ".") + { + return $true + } + + if ($scope -eq $env:COMPUTERNAME) + { + return $true + } + + if ($scope -eq "localhost") + { + return $true + } + + if ($scope.Contains(".")) + { + if ($scope -eq "127.0.0.1") + { + return $true + } + + # Determine if we have an ip address that matches an ip address on one of the network adapters. + # NOTE: This is likely overkill; consider removing it. + $networkAdapters = @(Get-CimInstance Win32_NetworkAdapterConfiguration) + foreach ($networkAdapter in $networkAdapters) + { + if ($null -ne $networkAdapter.IPAddress) + { + foreach ($address in $networkAdapter.IPAddress) + { + if ($address -eq $scope) + { + return $true + } + } + } + } + } + + return $false +} + +<# + .SYNOPSIS + Creates a user account. + + .DESCRIPTION + This function creates a user on the local or remote machine. + + .PARAMETER Credential + The credential containing the username and password to use to create the account. + + .PARAMETER Description + The optional description to set on the user account. + + .PARAMETER ComputerName + The optional name of the computer to update. Omit to create a user on the local machine. + + .NOTES + For remote machines, the currently logged on user must have rights to create a user. +#> +function New-User +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [PSCredential] + [System.Management.Automation.CredentialAttribute()] + $Credential, + + [string] + $Description, + + [string] + $ComputerName = $env:COMPUTERNAME + ) + + if (Test-IsNanoServer) + { + New-UserOnNanoServer @PSBoundParameters + } + else + { + New-UserOnFullSKU @PSBoundParameters + } +} + +<# + .SYNOPSIS + Creates a user account on a full server. + + .DESCRIPTION + This function creates a user on the local or remote machine running a full server. + + .PARAMETER Credential + The credential containing the username and password to use to create the account. + + .PARAMETER Description + The optional description to set on the user account. + + .PARAMETER ComputerName + The optional name of the computer to update. Omit to create a user on the local machine. + + .NOTES + For remote machines, the currently logged on user must have rights to create a user. +#> +function New-UserOnFullSKU +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [PSCredential] + [System.Management.Automation.CredentialAttribute()] + $Credential, + + [string] + $Description, + + [string] + $ComputerName = $env:COMPUTERNAME + ) + + Set-StrictMode -Version Latest + + $userName = $Credential.UserName + $password = $Credential.GetNetworkCredential().Password + + # Remove user if it already exists. + Remove-User $userName $ComputerName + + $adComputerEntry = [ADSI] "WinNT://$ComputerName" + $adUserEntry = $adComputerEntry.Create("User", $userName) + $null = $adUserEntry.SetPassword($password) + + if ($PSBoundParameters.ContainsKey("Description")) + { + $null = $adUserEntry.Put("Description", $Description) + } + + $null = $adUserEntry.SetInfo() +} + +<# + .SYNOPSIS + Creates a user account on a Nano server. + + .DESCRIPTION + This function creates a user on the local machine running a Nano server. + + .PARAMETER Credential + The credential containing the username and password to use to create the account. + + .PARAMETER Description + The optional description to set on the user account. + + .PARAMETER ComputerName + This parameter should not be used on NanoServer. +#> +function New-UserOnNanoServer +{ + + param ( + [Parameter(Mandatory = $true)] + [PSCredential] + [System.Management.Automation.CredentialAttribute()] + $Credential, + + [string] + $Description, + + [string] + $ComputerName = $env:COMPUTERNAME + ) + + Set-StrictMode -Version Latest + + if ($PSBoundParameters.ContainsKey("ComputerName")) + { + if (-not (Test-IsLocalMachine -Scope $ComputerName)) + { + throw "Do not specify the ComputerName arguments when running on NanoServer unless it is local machine." + } + } + + $userName = $Credential.UserName + $securePassword = $Credential.GetNetworkCredential().SecurePassword + + # Remove user if it already exists. + Remove-LocalUser -Name $userName -ErrorAction SilentlyContinue + + New-LocalUser -Name $userName -Password $securePassword + + if ($PSBoundParameters.ContainsKey("Description")) + { + Set-LocalUser -Name $userName -Description $Description + } +} + +<# + .SYNOPSIS + Removes a user account. + + .DESCRIPTION + This function removes a local user from the local or remote machine. + + .PARAMETER UserName + The name of the user to remove. + + .PARAMETER ComputerName + The optional name of the computer to update. Omit to remove the user on the local machine. + + .NOTES + For remote machines, the currently logged on user must have rights to remove a user. +#> +function Remove-User +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $UserName, + + [string] + $ComputerName = $env:COMPUTERNAME + ) + + if (Test-IsNanoServer) + { + Remove-UserOnNanoServer @PSBoundParameters + } + else + { + Remove-UserOnFullSKU @PSBoundParameters + } +} + +<# + .SYNOPSIS + Removes a user account on a full server. + + .DESCRIPTION + This function removes a local user from the local or remote machine running a full server. + + .PARAMETER UserName + The name of the user to remove. + + .PARAMETER ComputerName + The optional name of the computer to update. Omit to remove the user on the local machine. + + .NOTES + For remote machines, the currently logged on user must have rights to remove a user. +#> +function Remove-UserOnFullSKU +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $UserName, + + [string] + $ComputerName = $env:COMPUTERNAME + ) + + Set-StrictMode -Version Latest + + $adComputerEntry = [ADSI] "WinNT://$ComputerName" + + if ($adComputerEntry.Children | Where-Object Path -like "WinNT://*$ComputerName/$UserName") + { + $null = $adComputerEntry.Delete('user', $UserName) + } +} + +<# + .SYNOPSIS + Removes a local user account on a Nano server. + + .DESCRIPTION + This function removes a local user from the local machine running a Nano Server. + + .PARAMETER UserName + The name of the user to remove. + + .PARAMETER ComputerName + This parameter should not be used on NanoServer. +#> +function Remove-UserOnNanoServer +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string] + $UserName, + + [string] + $ComputerName = $env:COMPUTERNAME + ) + + Set-StrictMode -Version Latest + + if ($PSBoundParameters.ContainsKey("ComputerName")) + { + if (-not (Test-IsLocalMachine -Scope $ComputerName)) + { + throw "Do not specify the ComputerName arguments when running on NanoServer unless it is local machine." + } + } + + Remove-LocalUser -Name $UserName +} + +<# + .SYNOPSIS + Determines if a user exists.. + + .DESCRIPTION + This function determines if a user exists on a local or remote machine running. + + .PARAMETER UserName + The name of the user to test. + + .PARAMETER ComputerName + The optional name of the computer to update. +#> +function Test-User +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $UserName, + + [string] + $ComputerName = $env:COMPUTERNAME + ) + + if (Test-IsNanoServer) + { + Test-UserOnNanoServer @PSBoundParameters + } + else + { + Test-UserOnFullSKU @PSBoundParameters + } +} + +<# + .SYNOPSIS + Determines if a user exists on a full server. + + .DESCRIPTION + This function determines if a user exists on a local or remote machine running a full server. + + .PARAMETER UserName + The name of the user to test. + + .PARAMETER ComputerName + The optional name of the computer to update. +#> +function Test-UserOnFullSKU +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $UserName, + + [string] + $ComputerName = $env:COMPUTERNAME + ) + + Set-StrictMode -Version Latest + + $adComputerEntry = [ADSI] "WinNT://$ComputerName" + if ($adComputerEntry.Children | Where-Object Path -like "WinNT://*$ComputerName/$UserName") + { + return $true + } + + return $false +} + +<# + .SYNOPSIS + Determines if a user exists on a Nano server. + + .DESCRIPTION + This function determines if a user exists on a local or remote machine running a Nano server. + + .PARAMETER UserName + The name of the user to test. + + .PARAMETER ComputerName + This parameter should not be used on NanoServer. +#> +function Test-UserOnNanoServer +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $UserName, + + [string] + $ComputerName = $env:COMPUTERNAME + ) + + if ($PSBoundParameters.ContainsKey("ComputerName")) + { + if (-not (Test-IsLocalMachine -Scope $ComputerName)) + { + throw "Do not specify the ComputerName arguments when running on NanoServer unless it is local machine." + } + } + + # Try to find a group by its name. + try + { + $null = Get-LocalUser -Name $UserName -ErrorAction Stop + return $true + } + catch [System.Exception] + { + if ($_.CategoryInfo.ToString().Contains('UserNotFoundException')) + { + # A user with the provided name does not exist. + return $false + } + throw $_.Exception + } + + return $false + + Remove-LocalUser -Name $UserName +} + +<# + .SYNOPSIS + Waits for a script block to return true. + + .PARAMETER ScriptBlock + The ScriptBlock to wait. Should return a result of $true when complete. + + .PARAMETER TimeoutSeconds + The number of seconds to wait for the ScriptBlock to return true. +#> +function Wait-ScriptBlockReturnTrue +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [ScriptBlock] + $ScriptBlock, + + [Int] + $TimeoutSeconds = 5 + ) + + $startTime = [DateTime]::Now + + $invokeScriptBlockResult = $false + while (-not $invokeScriptBlockResult -and (([DateTime]::Now - $startTime).TotalSeconds -lt $TimeoutSeconds)) + { + $invokeScriptBlockResult = $ScriptBlock.Invoke() + Start-Sleep -Seconds 1 + } + + return $invokeScriptBlockResult +} + +<# + .SYNOPSIS + Tests if a file is currently locked. + + .PARAMETER Path + The path to the file to test. +#> +function Test-IsFileLocked +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $Path + ) + + if (-not (Test-Path $Path)) + { + return $false + } + + try + { + $content = Get-Content -Path $Path + return $false + } + catch + { + return $true + } +} + +<# + .SYNOPSIS + Tests that calling the Set-TargetResource cmdlet with the WhatIf parameter specified produces output that contains all the given expected output. + If empty or null expected output is specified, this cmdlet will check that there was no output from Set-TargetResource with WhatIf specified. + Uses Pester. + + .PARAMETER Parameters + The parameters to pass to Set-TargetResource. + These parameters do not need to contain that WhatIf parameter, but if they do, + this function will run Set-TargetResource with WhatIf = $true no matter what is in the Parameters Hashtable. + + .PARAMETER ExpectedOutput + The output expected to be in the output from running WhatIf with the Set-TargetResource cmdlet. + If this parameter is empty or null, this cmdlet will check that there was no output from Set-TargetResource with WhatIf specified. +#> +function Test-SetTargetResourceWithWhatIf +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [Hashtable] + $Parameters, + + [String[]] + $ExpectedOutput + ) + + $transcriptPath = Join-Path -Path (Get-Location) -ChildPath 'WhatIfTestTranscript.txt' + if (Test-Path -Path $transcriptPath) + { + Wait-ScriptBlockReturnTrue -ScriptBlock {-not (Test-IsFileLocked -Path $transcriptPath)} -TimeoutSeconds 10 + Remove-Item -Path $transcriptPath -Force + } + + $Parameters['WhatIf'] = $true + + try + { + Wait-ScriptBlockReturnTrue -ScriptBlock {-not (Test-IsFileLocked -Path $transcriptPath)} + + Start-Transcript -Path $transcriptPath + Set-TargetResource @Parameters + Stop-Transcript + + Wait-ScriptBlockReturnTrue -ScriptBlock {-not (Test-IsFileLocked -Path $transcriptPath)} + + $transcriptContent = Get-Content -Path $transcriptPath -Raw + $transcriptContent | Should Not Be $null + + $regexString = '\*+[^\*]*\*+' + + # Removing transcript diagnostic logging at top and bottom of file + $selectedString = Select-String -InputObject $transcriptContent -Pattern $regexString -AllMatches + + foreach ($match in $selectedString.Matches) + { + $transcriptContent = $transcriptContent.Replace($match.Captures, '') + } + + $transcriptContent = $transcriptContent.Replace("`r`n", "").Replace("`n", "") + + if ($null -eq $ExpectedOutput -or $ExpectedOutput.Count -eq 0) + { + [String]::IsNullOrEmpty($transcriptContent) | Should Be $true + } + else + { + foreach ($expectedOutputPiece in $ExpectedOutput) + { + $transcriptContent.Contains($expectedOutputPiece) | Should Be $true + } + } + } + finally + { + if (Test-Path -Path $transcriptPath) + { + Wait-ScriptBlockReturnTrue -ScriptBlock {-not (Test-IsFileLocked -Path $transcriptPath)} -TimeoutSeconds 10 + Remove-Item -Path $transcriptPath -Force + } + } +} + +<# + .SYNOPSIS + Enters a DSC Resource test environment. + + .PARAMETER DscResourceModuleName + The name of the module that contains the DSC Resource to test. + + .PARAMETER DscResourceName + The name of the DSC resource to test. + + .PARAMETER TestType + Specifies whether the test environment will run a Unit test or an Integration test. +#> +function Enter-DscResourceTestEnvironment +{ + [OutputType([PSObject])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $DscResourceModuleName, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $DscResourceName, + + [Parameter(Mandatory = $true)] + [ValidateSet('Unit', 'Integration')] + [String] + $TestType + ) + + if ((-not (Test-Path -Path "$PSScriptRoot\..\DSCResource.Tests")) -or (-not (Test-Path -Path "$PSScriptRoot\..\DSCResource.Tests\TestHelper.psm1"))) + { + Push-Location "$PSScriptRoot\.." + git clone https://github.com/PowerShell/DscResource.Tests.git --quiet + Pop-Location + } + else + { + $gitInstalled = $null -ne (Get-Command -Name 'git' -ErrorAction 'SilentlyContinue') + + if ($gitInstalled) + { + Push-Location "$PSScriptRoot\..\DSCResource.Tests" + git pull origin master --quiet + Pop-Location + } + else + { + Write-Verbose -Message "Git not installed. Leaving current DSCResource.Tests as is." + } + } + + Import-Module "$PSScriptRoot\..\DSCResource.Tests\TestHelper.psm1" + + return Initialize-TestEnvironment ` + -DSCModuleName $DscResourceModuleName ` + -DSCResourceName $DscResourceName ` + -TestType $TestType +} + +<# + .SYNOPSIS + Exits the specified DSC Resource test environment. + + .PARAMETER TestEnvironment + The test environment to exit. +#> +function Exit-DscResourceTestEnvironment +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [PSObject]$TestEnvironment + ) + + Import-Module "$PSScriptRoot\..\DSCResource.Tests\TestHelper.psm1" + + Restore-TestEnvironment -TestEnvironment $TestEnvironment +} + +Export-ModuleMember -Function ` + Test-GetTargetResourceResult, ` + New-User, ` + Remove-User, ` + Test-User, ` + Wait-ScriptBlockReturnTrue, ` + Test-IsFileLocked, ` + Test-SetTargetResourceWithWhatIf, ` + Enter-DscResourceTestEnvironment, ` + Exit-DscResourceTestEnvironment diff --git a/Tests/Unit/MSFT_WindowsOptionalFeature.Tests.ps1 b/Tests/Unit/MSFT_WindowsOptionalFeature.Tests.ps1 new file mode 100644 index 0000000..194ebef --- /dev/null +++ b/Tests/Unit/MSFT_WindowsOptionalFeature.Tests.ps1 @@ -0,0 +1,322 @@ +Import-Module -Name (Join-Path -Path (Join-Path -Path (Split-Path $PSScriptRoot -Parent) -ChildPath 'TestHelpers') -ChildPath 'CommonTestHelper.psm1') + +$script:testEnvironment = Enter-DscResourceTestEnvironment ` + -DscResourceModuleName 'PSDscResources' ` + -DscResourceName 'MSFT_WindowsOptionalFeature' ` + -TestType 'Unit' + +try +{ + InModuleScope 'MSFT_WindowsOptionalFeature' { + Describe 'WindowsOptionalFeature Unit Tests' { + BeforeAll { + Import-Module -Name 'Dism' + + $script:testFeatureName = 'TestFeature' + + $script:fakeEnabledFeature = [PSCustomObject] @{ + Name = $testFeatureName + State = 'Enabled' + } + + $script:fakeDisabledFeature = [PSCustomObject] @{ + Name = $testFeatureName + State = 'Disabled' + } + } + + <# + This context block needs to stay at the top because of a bug in Pester on Nano server. + + Assert-ResourcePrerequisitesValid is mocked in most of the other contexts blocks. + This causes errors to throw from the script blocks in this context since this function does + not take any parameters, but Pester tries to pipe something into it. + + This bug does not occur on full server machines. + #> + Context 'Assert-ResourcePrerequisitesValid' { + $fakeWin32OSObjects = @{ + '7' = [PSCustomObject] @{ + ProductType = 1 + BuildNumber = 7601 + } + 'Server2008R2' = [PSCustomObject] @{ + ProductType = 2 + BuildNumber = 7601 + } + 'Server2012' = [PSCustomObject] @{ + ProductType = 2 + BuildNumber = 9200 + } + '8.1' = [PSCustomObject] @{ + ProductType = 1 + BuildNumber = 9600 + } + 'Server2012R2' = [PSCustomObject] @{ + ProductType = 2 + BuildNumber = 9600 + } + } + + It 'Should throw when the DISM module is not available' { + Mock Import-Module -ParameterFilter { $Name -eq 'Dism' } -MockWith { Write-Error 'Cannot find module' } + { Assert-ResourcePrerequisitesValid } | Should Throw $script:localizedData.DismNotAvailable + } + + Mock Import-Module -ParameterFilter { $Name -eq 'Dism' } -MockWith { } + + It 'Should throw when operating system is Server 2008 R2' { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } -MockWith { return $fakeWin32OSObjects['Server2008R2'] } + { Assert-ResourcePrerequisitesValid } | Should Throw $script:localizedData.NotSupportedSku + } + + It 'Should throw when operating system is Server 2012' { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } -MockWith { return $fakeWin32OSObjects['Server2012'] } + { Assert-ResourcePrerequisitesValid } | Should Throw $script:localizedData.NotSupportedSku + } + + It 'Should not throw when operating system is Windows 7' { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } -MockWith { return $fakeWin32OSObjects['7'] } + { Assert-ResourcePrerequisitesValid } | Should Not Throw + } + + It 'Should not throw when operating system is Windows 8.1' { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } -MockWith { return $fakeWin32OSObjects['8.1'] } + { Assert-ResourcePrerequisitesValid } | Should Not Throw + } + + It 'Should not throw when operating system is Server 2012 R2' { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } -MockWith { return $fakeWin32OSObjects['Server2012R2'] } + { Assert-ResourcePrerequisitesValid } | Should Not Throw + } + } + + Context 'Get-TargetResource - Feature Enabled' { + Mock Assert-ResourcePrerequisitesValid -MockWith { } + Mock Dism\Get-WindowsOptionalFeature { $FeatureName -eq $script:testFeatureName } -MockWith { return $script:fakeEnabledFeature } + + It 'Should return a Hashtable' { + $getTargetResourceResult = Get-TargetResource -Name $script:testFeatureName + $getTargetResourceResult -is [System.Collections.Hashtable] | Should Be $true + } + + It 'Should call Assert-ResourcePrerequisitesValid with the feature name' { + $getTargetResourceResult = Get-TargetResource -Name $script:testFeatureName + Assert-MockCalled Dism\Get-WindowsOptionalFeature -ParameterFilter { $FeatureName -eq $script:testFeatureName } -Scope It + } + + It 'Should return Ensure as Present' { + $getTargetResourceResult = Get-TargetResource -Name $script:testFeatureName + $getTargetResourceResult.Ensure | Should Be 'Present' + } + } + + + Context 'Get-TargetResource - Feature Disabled' { + Mock Assert-ResourcePrerequisitesValid -MockWith { } + Mock Dism\Get-WindowsOptionalFeature { $FeatureName -eq $script:testFeatureName } -MockWith { return $script:fakeDisabledFeature } + + It 'Should return Ensure as Absent' { + $getTargetResourceResult = Get-TargetResource -Name $script:testFeatureName + $getTargetResourceResult.Ensure | Should Be 'Absent' + } + } + + Context 'Test-TargetResource - Feature Enabled' { + Mock Assert-ResourcePrerequisitesValid -MockWith { } + Mock Dism\Get-WindowsOptionalFeature { $FeatureName -eq $script:testFeatureName } -MockWith { return $script:fakeEnabledFeature } + + It 'Should return true when Ensure set to Present' { + Test-TargetResource -Name $testFeatureName -Ensure 'Present' | Should Be $true + } + + It 'Should return false when Ensure set to Absent' { + Test-TargetResource -Name $testFeatureName -Ensure 'Absent' | Should Be $false + } + + } + + Context 'Test-TargetResource - Feature Disabled' { + Mock Assert-ResourcePrerequisitesValid -MockWith { } + Mock Dism\Get-WindowsOptionalFeature { $FeatureName -eq $script:testFeatureName } -MockWith { return $script:fakeDisabledFeature } + + It 'Should return false when Ensure set to Present' { + Test-TargetResource -Name $testFeatureName -Ensure 'Present' | Should Be $false + } + + It 'Should return true when Ensure set to Absent' { + Test-TargetResource -Name $testFeatureName -Ensure 'Absent' | Should Be $true + } + } + + Context 'Test-TargetResource - Feature Missing' { + Mock Assert-ResourcePrerequisitesValid -MockWith { } + Mock Dism\Get-WindowsOptionalFeature { $FeatureName -eq $script:testFeatureName } -MockWith { } + + It 'Should return false when Ensure set to Present' { + Test-TargetResource -Name $testFeatureName -Ensure 'Present' | Should Be $false + } + + It 'Should return true when Ensure set to Absent' { + Test-TargetResource -Name $testFeatureName -Ensure 'Absent' | Should Be $true + } + } + + Context 'Set-TargetResource' { + Mock Assert-ResourcePrerequisitesValid -MockWith { } + + It 'Should call Enable-WindowsOptionalFeature with NoRestart set to true by default when Ensure set to Present' { + Mock Dism\Enable-WindowsOptionalFeature -ParameterFilter { $NoRestart -eq $true } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName + + Assert-MockCalled Dism\Enable-WindowsOptionalFeature -ParameterFilter { $NoRestart -eq $true } -Scope It + } + + It 'Should call Disable-WindowsOptionalFeature with NoRestart set to true by default when Ensure set to Absent' { + Mock Dism\Disable-WindowsOptionalFeature -ParameterFilter { $NoRestart -eq $true } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName -Ensure 'Absent' + + Assert-MockCalled Dism\Disable-WindowsOptionalFeature -ParameterFilter { $NoRestart -eq $true } -Scope It + } + + It 'Should call Enable-WindowsOptionalFeature with Online by default as true when Ensure set to Present' { + Mock Dism\Enable-WindowsOptionalFeature -ParameterFilter { $Online -eq $true } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName + + Assert-MockCalled Dism\Enable-WindowsOptionalFeature -ParameterFilter { $Online -eq $true } -Scope It + } + + It 'Should call Disable-WindowsOptionalFeature with Online set to true by default when Ensure set to Absent' { + Mock Dism\Disable-WindowsOptionalFeature -ParameterFilter { $Online -eq $true } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName -Ensure 'Absent' + + Assert-MockCalled Dism\Disable-WindowsOptionalFeature -ParameterFilter { $Online -eq $true } -Scope It + } + + It 'Should call Enable-WindowsOptionalFeature with LogLevel set to WarningsInfo by default when Ensure set to Present' { + Mock Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LogLevel -eq 'WarningsInfo' } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName + + Assert-MockCalled Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LogLevel -eq 'WarningsInfo' } -Scope It + } + + It 'Should call Enable-WindowsOptionalFeature with LogLevel set to Errors when Ensure set to Present and LogLevel set to ErrorsOnly' { + Mock Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LogLevel -eq 'Errors' } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName -LogLevel 'ErrorsOnly' + + Assert-MockCalled Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LogLevel -eq 'Errors' } -Scope It + } + + It 'Should call Enable-WindowsOptionalFeature with LogLevel set to Warnings when Ensure set to Present and LogLevel set to ErrorsAndWarnings' { + Mock Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LogLevel -eq 'Warnings' } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName -LogLevel 'ErrorsAndWarning' + + Assert-MockCalled Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LogLevel -eq 'Warnings' } -Scope It + } + + It 'Should call Disable-WindowsOptionalFeature with LogLevel set to WarningsInfo by default when Ensure set to Absent' { + Mock Dism\Disable-WindowsOptionalFeature -ParameterFilter { $LogLevel -eq 'WarningsInfo' } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName -Ensure 'Absent' + + Assert-MockCalled Dism\Disable-WindowsOptionalFeature -ParameterFilter { $LogLevel -eq 'WarningsInfo' } -Scope It + } + + It 'Should call Enable-WindowsOptionalFeature without LimitAccess by default when Ensure set to Present' { + Mock Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LimitAccess -eq $null } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName + + Assert-MockCalled Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LimitAccess -eq $null } -Scope It + } + + It 'Should call Enable-WindowsOptionalFeature with LimitAccess set to true when NoWindowsUpdateCheck is specified' { + Mock Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LimitAccess -eq $true } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName -NoWindowsUpdateCheck $true + + Assert-MockCalled Dism\Enable-WindowsOptionalFeature -ParameterFilter { $LimitAccess -eq $true } -Scope It + } + + It 'Should call Disable-WindowsOptionalFeature with Remove set to true when Ensure set to Absent and RemoveFilesOnDisable specified' { + Mock Dism\Disable-WindowsOptionalFeature -ParameterFilter { $Remove -eq $true } -MockWith { } + + Set-TargetResource -Name $script:testFeatureName -Ensure 'Absent' -RemoveFilesOnDisable $true + + Assert-MockCalled Dism\Disable-WindowsOptionalFeature -ParameterFilter { $Remove -eq $true } -Scope It + } + + } + + Context 'Convert-FeatureStateToEnsure' { + It 'Should return Present when state is Enabled' { + Convert-FeatureStateToEnsure -State 'Enabled' | Should Be 'Present' + } + + It 'Should return Absent when state is Disabled' { + Convert-FeatureStateToEnsure -State 'Disabled' | Should Be 'Absent' + } + + It 'Should return the same state when state is not Enabled or Disabled' { + $originalWarningPreference = $WarningPreference + $WarningPreference = 'SilentlyContinue' + + try + { + Convert-FeatureStateToEnsure -State 'UnknownState' | Should Be 'UnknownState' + } + finally + { + $WarningPreference = $originalWarningPreference + } + } + } + + Context 'Convert-CustomPropertyArrayToStringArray' { + [PSCustomObject[]] $psCustomObjects = @( + [PSCustomObject] @{ + Name = 'Object 1' + Value = 'Value 1' + Path = 'Path 1' + }, + [PSCustomObject] @{ + Name = 'Object 2' + Value = 'Value 2' + Path = 'Path 2' + }, + [PSCustomObject] @{ + Name = 'Object 3' + Value = 'Value 3' + Path = 'Path 3' + }, + $null + ) + + It 'Should return 3 strings from 3 PSCustomObjects and a null object' { + $propertiesAsStrings = Convert-CustomPropertyArrayToStringArray -CustomProperties $psCustomObjects + $propertiesAsStrings.Length | Should Be 3 + } + + It 'Should return the correct string for each object' { + $propertiesAsStrings = Convert-CustomPropertyArrayToStringArray -CustomProperties $psCustomObjects + + foreach ($objectNumber in @(1, 2, 3)) + { + $propertiesAsStrings.Contains("Name = Object $objectNumber, Value = Value $objectNumber, Path = Path $objectNumber") | Should Be $true + } + } + } + } + } +} +finally +{ + Exit-DscResourceTestEnvironment -TestEnvironment $script:testEnvironment +}