From 1a7b762f5da5430ef49384fd31703b41acaf0457 Mon Sep 17 00:00:00 2001 From: Katie Keim Date: Fri, 7 Oct 2016 20:58:29 -0700 Subject: [PATCH] Add WindowsOptionalFeature resource --- .gitignore | 1 + DscResources/CommonResourceHelper.psm1 | 142 ++++ .../MSFT_WindowsOptionalFeature.psm1 | 385 +++++++++ .../MSFT_WindowsOptionalFeature.schema.mof | Bin 0 -> 3528 bytes .../MSFT_WindowsOptionalFeature.schema.mfl | Bin 0 -> 3250 bytes .../MSFT_WindowsOptionalFeature.strings.psd1 | 20 + Examples/Sample_WindowsOptionalFeature.ps1 | 38 + PSDscResources.psd1 | Bin 0 -> 8120 bytes README.md | 51 +- ...ndowsOptionalFeature.Integration.Tests.ps1 | 152 ++++ .../MSFT_WindowsOptionalFeature.config.ps1 | 43 + Tests/TestHelpers/CommonTestHelper.psm1 | 738 ++++++++++++++++++ .../MSFT_WindowsOptionalFeature.Tests.ps1 | 322 ++++++++ 13 files changed, 1891 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 DscResources/CommonResourceHelper.psm1 create mode 100644 DscResources/MSFT_WindowsOptionalFeature/MSFT_WindowsOptionalFeature.psm1 create mode 100644 DscResources/MSFT_WindowsOptionalFeature/MSFT_WindowsOptionalFeature.schema.mof create mode 100644 DscResources/MSFT_WindowsOptionalFeature/en-US/MSFT_WindowsOptionalFeature.schema.mfl create mode 100644 DscResources/MSFT_WindowsOptionalFeature/en-US/MSFT_WindowsOptionalFeature.strings.psd1 create mode 100644 Examples/Sample_WindowsOptionalFeature.ps1 create mode 100644 PSDscResources.psd1 create mode 100644 Tests/Integration/MSFT_WindowsOptionalFeature.Integration.Tests.ps1 create mode 100644 Tests/Integration/MSFT_WindowsOptionalFeature.config.ps1 create mode 100644 Tests/TestHelpers/CommonTestHelper.psm1 create mode 100644 Tests/Unit/MSFT_WindowsOptionalFeature.Tests.ps1 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 0000000000000000000000000000000000000000..15aefc29ab1d525c6b969a941b1803896abd89b7 GIT binary patch literal 3528 zcmc&%T~AX%5S?ce|HG#7fhLCNt3Fkr2@!$N0uO|kOFyu&AEft6Z3ur|IcMfByW8H2 zBB*Jky?bY8&Y3wgyZ-#~QeMcJY{^(M$?-Xnfz0tel9^0pLmKi{Hs#IzzlJpBwK!?R zY6RP<^s#p-``AChGk2Wg4cBL~fTshwlA`pN!L|*n3@hK@VF2rz#5AjSML{dbN(tOsjT8%|cb&*=&9O8J*pP*)AWMu$_ zW!QUz%B7eZT9byv`x|nrt6_Kkh@Iy^nd)K(?DMjFo>LplKlSUB(UDqTJOO$K@D=MurtznQ*$;&9D06+4>4!3b=vzr zQv06G0p4xnDfHlT=5e*htWT!E#oTUVHg%vMhfH$97C_p=U*Z^yGQt!dSSs_s*E^{!Uw70}GJ2W^CRGyghO4pm7WmL1dY z0?$-mQ0W*uFLegBfQ#n}PYdgcYs9cQVVy(0^JLmV<~Z4SzEXocH(6KezKN6c5kz6v15kX#oXhWrn8N6C)_SMEx0GWKk~%2 zzOYF!2X4*_yLA#3Z{VEyc{EqkTx>=7c~kLOWOwphKf~SoX&GX_=9quP5*Rr=|BbBF f)x5frPi02sU7Hi9D%j(?$0&r>0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ccd55080a323bca25f936e839fd9cff1eaee27f9 GIT binary patch literal 3250 zcmcImO>fgc5S=SX{D&3dkV*(~-~yb|HkB&Uq#}tpL{*i>PKj{hD0UkV;;&QQoAF|A z;)Gf%s_J;XAM@tTo0)z1J(g36C6lSFWiBhp@l0hYEqN(J`HE2{4s%?S${pUOcz4D3 zJA92L!Z^pQ!~0SuawCbZnP9}ZNLQENzhKOnR5p5gTCqF9UMDl`pWsZ|qvbb1bA<07 z@ZXk&+KKRtddeJvrv{uaKo0G*-g!@@as9_wkbd{*2gW3=^CEk2X6e8TJx#d(;0GEGM5u>)T- zJaa^aqmZNe2&CX|n}5$`9h+oj01MQ@Qf@Ko71lrg#l9c$06fKDbPjBDyiqwDMQfOe zWgq$s>&RB3in&n_t7@W->b!}d4TqTRs;}M>wL#711s2oy3LaC1F@AGI#urA=k0$#D^_Ntk)uj!_M#B2Lje+01 zEq3)Aa7Q1=c-@J{=r-cRRUqtYU1hWjTNdpm3RhKRqu^;$DC6Y7&3z en=8~4 +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 0000000000000000000000000000000000000000..f472623489a164b53f55a65229c42e213abd1398 GIT binary patch literal 8120 zcmeI1YfoE85Qg`2rTzy?`2eX36q-v@sZtP^29;a{B<)vYY}bu%F(xFc^4HtG&yJVf zJ!fse3T;(Y795{*c4ueaxsU(+$|q@ zMtak5r+PbdO@04ZThY-wTibN!`iw-ar?peythB!2IQywvb@wxA58SSv zUr1gc8>U!6uA_+g&?8|Sy!ZH=-H@N0#tX?f(0imbHC*-iNxiz9rlD`b!d zFEF2hKKg4&Qsa2O205)t$uL_LV)sR3OWN9WkMyn1>pT?KwR&6j)Ymogh~G}c8x}Zm zpm-+V$PwT5?_)Fxz8SR+b{%iGEy-x_n>q=f!G6c0JrZ@ZW8%l@HC8 zG=vWJ0_H?EWCLZmGffO79q|qhcIDSQx|;MOaX-<1kf_QJ#(3mFYX;f@N@D&_ULKAl>O<5z$$W-$4b@xJ<=|qU`%Wf=&T?)sEdwXl$5FMgd;jdV1 zuuAeV`H)P6SH0J1csG`s#xquww2QalS#L#!*w)Zpno-H@Ls9%FTP0s$CA)eym4t2m zugmxOkPQ)n*fG$@>W;o7#6vH1?Rv>4`feH6a>c5m$Dup&@i-#Ix9j}(--oqHauZBn z$ttJ?(I$DDs*H%*@(~#uAp;Ogv4vIX4?NrMYu){#n&OfFwk@2`^!>pS=mqzlL$})eDKKnDt=^V^nbKPC~ssrh7Z)@TF+wi>a?VDZ@5Bg{LPIdDlDBU#J*nYFlBkikp$s1YET(_JM708o; zpQ;pPpW65NEY|DrZSuC=hpTvp2C-D?Uc4uC`cYmYd#C}hKZ~5mIGH`Sxd1tXwN%F7 zhoqtRwRu8c@9<3ObGRV`lS}g*#3Ve|>XG2l7GarWaE5i%MgiX@ep@F3b!sK+s>3wF zY8}gDL=Ct@Dl-0c2$`4H(L`5!8Iol^?1;OwnMD8VpsIt`wdffBIf^&M{*AXb=2|wp zSr&i1uuiMY#ftl?(v8uT!vj6|vK;((z0f+1Md+9X~m-7yyGJul7G+^7E+yC@rFM7Se6ucr>;F${zVdtRjCfC)YE<%%DEMAN6utrpc&`R zHjOKF?&e!LrjaYtK>XdIp;P(fyw6(|R^QW(6LHn`T4~B!$KpTKMpKVax%LRS;%Ve< zW_^9_PO-`L3QBk<(@3(u)d5t;Hi4iLgHl)g;7hErZpn}cJcn!K8!1<2bB-{_JNAfU zhs<<`swkKZV1?O%3y;f{G)HNcD#2`+M z#@v+qP}9vro(K^rhbz7rX}tE>g{eBWJC$zE-S=)oAEuwszK}hydyNeAv}A)k#TUv^ zb;J7uz4wM}94`UyiS7>dJIIb|uov3Bm*m{QZMZJR1)5f$UyAOj=!aQY9U=PUk^4>S zs2a-_W1U}E^Y>tMTbrQrreCyqy3Ki!&HT)!V2frIWy!($U`8{Sdoh1_0iI|z023uN zOaza8R=qDN+2?#p=Di1!8|_k!5kZkSo*d3wWQ}!6K8X&J?};{<*Lcn&WIejWJ%96d zr#UXhnK-C?gG7v*Lx)83A2%D4MM(|-TqNl*Py zj4WZ|YJDF1Ogm5OjIZlH$p9~Bu^de34vB2`s}Fbc`5^z{+05Jy6g$F +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 +}