From faadd61ae0562252603755cd58b62550b1fa2342 Mon Sep 17 00:00:00 2001 From: Justin Ainsworth Date: Tue, 14 Dec 2021 17:50:08 -0800 Subject: [PATCH 1/8] Added support for Windows RSAT Modules --- PSDepend/PSDependMap.psd1 | 6 + PSDepend/PSDependScripts/WindowsRSAT.ps1 | 164 +++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 PSDepend/PSDependScripts/WindowsRSAT.ps1 diff --git a/PSDepend/PSDependMap.psd1 b/PSDepend/PSDependMap.psd1 index d150c72..0b1299e 100644 --- a/PSDepend/PSDependMap.psd1 +++ b/PSDepend/PSDependMap.psd1 @@ -89,4 +89,10 @@ Description = 'Support dependencies by handling simple tasks' Supports = 'windows', 'core', 'macos', 'linux' } + + WindowsRSAT = @{ + Script = 'WindowsRSAT.ps1' + Description = 'Install a WindowsRSAT PowerShell module using Add-WindowsCapability or Install-WindowsFeature, depending on OS' + Supports = 'windows' + } } diff --git a/PSDepend/PSDependScripts/WindowsRSAT.ps1 b/PSDepend/PSDependScripts/WindowsRSAT.ps1 new file mode 100644 index 0000000..cb3f380 --- /dev/null +++ b/PSDepend/PSDependScripts/WindowsRSAT.ps1 @@ -0,0 +1,164 @@ +<# + .SYNOPSIS + 'Install a WindowsRSAT PowerShell module using Add-WindowsCapability or Install-WindowsFeature, depending on OS' + + .DESCRIPTION + Installs a RSAT Module in Windows. + + Relevant Dependency metadata: + Name: The name for the module to install + + .PARAMETER PSDependAction + Test, Install, or Import the module. Defaults to Install + + Test: Return true or false on whether the dependency is in place + Install: Install the dependency + Import: Import the dependency + + .EXAMPLE + @{ + ActiveDirectory = @{ + DependencyType = 'WindowsRSAT' + Name = 'ActiveDirectory' + } + } +#> +[cmdletbinding()] +param( + [PSTypeName('PSDepend.Dependency')] + [psobject[]]$Dependency, + + [ValidateSet('Test', 'Install', 'Import')] + [string[]]$PSDependAction = @('Install') +) + + +$RSAT_MODULE_MAP = @{ + 'ActiveDirectory' = @{ + 'WindowsFeature' = 'RSAT-AD-Powershell' + 'WindowsCapability' = 'Rsat.ActiveDirectory.DS-LDS.Tools' + } + 'ADDSDeployment' = @{ + 'WindowsFeature' = 'RSAT-AD-Powershell' + 'WindowsCapability' = 'Rsat.ActiveDirectory.DS-LDS.Tools' + } + 'ADCSAdministration' = @{ + 'WindowsFeature' = 'RSAT-ADCS-Mgmt' + 'WindowsCapability' = 'Rsat.CertificateServices.Tools' + } + 'ADCSDeployment' = @{ + 'WindowsFeature' = 'RSAT-ADCS-Mgmt' + 'WindowsCapability' = 'Rsat.CertificateServices.Tools' + } + 'ADRMS' = @{ + 'WindowsFeature' = 'RSAT-ADRMS' + #'WindowsCapability' = 'Rsat.CertificateServices.Tools' + } + 'ADRMSAdmin' = @{ + 'WindowsFeature' = 'RSAT-ADRMS' + #'WindowsCapability' = 'Rsat.CertificateServices.Tools' + } + 'BitLocker' = @{ + 'WindowsFeature' = 'RSAT-Feature-Tools-BitLocket-RemoteAdminTool' + 'WindowsCapability' = 'Rsat.BitLocker.Recovery.Tools' + } + 'BitsTransfer' = @{ + 'WindowsFeature' = 'RSAT-Bits-Server' + #'WindowsCapability' = 'Rsat.BitLocker.Recovery.Tools' + } + 'DFSN' = @{ + 'WindowsFeature' = 'RSAT-DFS-Mgmt-Con' + #'WindowsCapability' = 'Rsat.BitLocker.Recovery.Tools' + } + 'DFSR' = @{ + 'WindowsFeature' = 'RSAT-DFS-Mgmt-Con' + #'WindowsCapability' = 'Rsat.BitLocker.Recovery.Tools' + } + 'DHCP' = @{ + 'WindowsFeature' = 'RSAT-DHCP' + 'WindowsCapability' = 'Rsat.DHCP.Tools' + } + 'DNSClient' = @{ + 'WindowsFeature' = 'RSAT-DNS-Server' + 'WindowsCapability' = 'rsat.dns.tools' + } + 'DNSServer' = @{ + 'WindowsFeature' = 'RSAT-DNS-Server' + 'WindowsCapability' = 'rsat.dns.tools' + } + 'FailoverClusters' = @{ + 'WindowsFeature' = 'RSAT-Clustering-PowerShell' + 'WindowsCapability' = 'Rsat.FailoverCluster.Management.Tools' + } + 'FileServerResourceManager' = @{ + 'WindowsFeature' = 'RSAT-FSRM-Mgmt' + #'WindowsCapability' = 'Rsat.FileServices.Tools' + } + 'GroupPolicy' = @{ + 'WindowsFeature' = 'RSAT' + 'WindowsCapability' = 'Rsat.GroupPolicy.Management.Tools' + } + 'Hyper-V' = @{ + 'WindowsFeature' = 'RSAT-Huper-V-Tools' + #'WindowsCapability' = 'Rsat.GroupPolicy.Management.Tools' + } + 'IISAdministration' = @{ + 'WindowsFeature' = 'web-mgmt-console' + #'WindowsCapability' = 'Rsat.GroupPolicy.Management.Tools' + } + 'RemoteAccess' = @{ + 'WindowsFeature' = 'RSAT-RemoteAccess-Powershell' + 'WindowsCapability' = 'Rsat.RemoteAccess.Management.Tools' + } + 'VAMT' = @{ + 'WindowsFeature' = 'RSAT-VA-Tools' + 'WindowsCapability' = 'Rsat.VolumeActivation.Tools' + } +} + +# Extract data from Dependency +$ModuleName = $Dependency.Name +if (-not $ModuleName) { + $ModuleName = $Dependency.DependencyName +} + +if (Get-Module -ListAvailable -Name $ModuleName -ErrorAction SilentlyContinue) { + Write-Verbose "Found existing module [$ModuleName]" + if ($PSDependAction -contains 'Test') { + return $True + } + return $null +} + +#No dependency found, return false if we're testing alone... +if ( $PSDependAction -contains 'Test' -and $PSDependAction.count -eq 1) { + return $False +} + +if ($PSDependAction -contains 'Install') { + + if (-not ((New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))) { + throw "Must be an admin to install RSAT modules" + } + + #Server + $Type = 'WindowsFeature' + if ((get-CimInstance -ClassName Win32_OperatingSystem).ProductType -eq 1) { + # Workstation + $Type = 'WindowsCapability' + } + + if ($null -eq $RSAT_MODULE_MAP[$ModuleName][$type]) { + throw "Unknown Module $ModuleName" + } + + if ($Type -eq 'WindowsFeature') { + $null = install-windowsfeature -name $RSAT_MODULE_MAP[$ModuleName][$Type] + } + else { + $null = Add-WindowsCapability -Online -Name $RSAT_MODULE_MAP[$ModuleName][$Type] + } +} + +# Conditional import +Import-PSDependModule -Name $ModuleName -Action $PSDependAction \ No newline at end of file From 604d848ae061de3b0ae29ccffcdbee3c5b88fb48 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 15 May 2026 23:07:47 -0400 Subject: [PATCH 2/8] fix(WindowsRSAT): correct Server feature name typos (BitLocker, Hyper-V) The original PR contained two typos in WindowsFeature names that would cause Install-WindowsFeature to fail on Windows Server: - 'BitLocket' -> 'BitLocker' in RSAT-Feature-Tools-BitLocker-RemoteAdminTool - 'Huper-V' -> 'Hyper-V' in RSAT-Hyper-V-Tools Verified against Windows Server 2022 catalog via Get-WindowsFeature. Co-Authored-By: Claude Opus 4.7 (1M context) --- PSDepend/PSDependScripts/WindowsRSAT.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PSDepend/PSDependScripts/WindowsRSAT.ps1 b/PSDepend/PSDependScripts/WindowsRSAT.ps1 index cb3f380..a62de38 100644 --- a/PSDepend/PSDependScripts/WindowsRSAT.ps1 +++ b/PSDepend/PSDependScripts/WindowsRSAT.ps1 @@ -59,7 +59,7 @@ $RSAT_MODULE_MAP = @{ #'WindowsCapability' = 'Rsat.CertificateServices.Tools' } 'BitLocker' = @{ - 'WindowsFeature' = 'RSAT-Feature-Tools-BitLocket-RemoteAdminTool' + 'WindowsFeature' = 'RSAT-Feature-Tools-BitLocker-RemoteAdminTool' 'WindowsCapability' = 'Rsat.BitLocker.Recovery.Tools' } 'BitsTransfer' = @{ @@ -99,7 +99,7 @@ $RSAT_MODULE_MAP = @{ 'WindowsCapability' = 'Rsat.GroupPolicy.Management.Tools' } 'Hyper-V' = @{ - 'WindowsFeature' = 'RSAT-Huper-V-Tools' + 'WindowsFeature' = 'RSAT-Hyper-V-Tools' #'WindowsCapability' = 'Rsat.GroupPolicy.Management.Tools' } 'IISAdministration' = @{ From ef0ac7cab497ea689bfad11a49f974838423e033 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 15 May 2026 23:10:41 -0400 Subject: [PATCH 3/8] fix(WindowsRSAT): use GPMC for GroupPolicy server feature The mapping pointed to 'RSAT', the umbrella Remote Server Administration Tools feature. Installing it pulls in every RSAT sub-feature on Server, not just Group Policy management. The targeted feature is GPMC ("Group Policy Management"), which installs the GroupPolicy module without the rest of RSAT. Verified against Windows Server 2022 catalog via Get-WindowsFeature. Co-Authored-By: Claude Opus 4.7 (1M context) --- PSDepend/PSDependScripts/WindowsRSAT.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PSDepend/PSDependScripts/WindowsRSAT.ps1 b/PSDepend/PSDependScripts/WindowsRSAT.ps1 index a62de38..fa31e09 100644 --- a/PSDepend/PSDependScripts/WindowsRSAT.ps1 +++ b/PSDepend/PSDependScripts/WindowsRSAT.ps1 @@ -95,7 +95,7 @@ $RSAT_MODULE_MAP = @{ #'WindowsCapability' = 'Rsat.FileServices.Tools' } 'GroupPolicy' = @{ - 'WindowsFeature' = 'RSAT' + 'WindowsFeature' = 'GPMC' 'WindowsCapability' = 'Rsat.GroupPolicy.Management.Tools' } 'Hyper-V' = @{ From 105d3258cdbcc685c0aaea4b73676000be958f5b Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 15 May 2026 23:12:00 -0400 Subject: [PATCH 4/8] fix(WindowsRSAT): drop BitsTransfer entry (ships in-box on Windows) The BitsTransfer module ships in-box on both Windows Server and Windows client; no RSAT install is required for its cmdlets. The mapping pointed to 'RSAT-Bits-Server' (BITS Server Extensions Tools), which manages remote BITS server endpoints rather than the BitsTransfer cmdlets. With the in-box module present, the Get-Module -ListAvailable check at the top of the script short-circuits before reaching the install branch, so the mapping was both wrong and effectively dead code. No Rsat.Bits.* capability (Win 11) or RSAT-BitsTransfer-* feature (Server 2022) exists to map this to, so removing the entry. Verified against Server 2022 catalog and Win 11 capability list. Co-Authored-By: Claude Opus 4.7 (1M context) --- PSDepend/PSDependScripts/WindowsRSAT.ps1 | 4 ---- 1 file changed, 4 deletions(-) diff --git a/PSDepend/PSDependScripts/WindowsRSAT.ps1 b/PSDepend/PSDependScripts/WindowsRSAT.ps1 index fa31e09..1628d14 100644 --- a/PSDepend/PSDependScripts/WindowsRSAT.ps1 +++ b/PSDepend/PSDependScripts/WindowsRSAT.ps1 @@ -62,10 +62,6 @@ $RSAT_MODULE_MAP = @{ 'WindowsFeature' = 'RSAT-Feature-Tools-BitLocker-RemoteAdminTool' 'WindowsCapability' = 'Rsat.BitLocker.Recovery.Tools' } - 'BitsTransfer' = @{ - 'WindowsFeature' = 'RSAT-Bits-Server' - #'WindowsCapability' = 'Rsat.BitLocker.Recovery.Tools' - } 'DFSN' = @{ 'WindowsFeature' = 'RSAT-DFS-Mgmt-Con' #'WindowsCapability' = 'Rsat.BitLocker.Recovery.Tools' From 9cefcae72552334ce8fa84448ce260d4867cf337 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 15 May 2026 23:24:11 -0400 Subject: [PATCH 5/8] refactor(WindowsRSAT): extract admin check to private helper Moves the inline WindowsPrincipal/WindowsBuiltInRole.Administrator check into PSDepend/Private/Test-Administrator.ps1. The module loader already dot-sources everything under Private/ (see PSDepend.psm1), so the helper becomes a module-scope function alongside the existing 20 private helpers. This makes the Install branch's admin guard mockable from Pester via InModuleScope PSDepend { Mock Test-Administrator }, so the dispatch tests in the upcoming test file run on any developer machine regardless of elevation rather than skipping on non-admin hosts. No behavior change: the helper performs the same check as the inline expression it replaces. Co-Authored-By: Claude Opus 4.7 (1M context) --- PSDepend/PSDependScripts/WindowsRSAT.ps1 | 2 +- PSDepend/Private/Test-Administrator.ps1 | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 PSDepend/Private/Test-Administrator.ps1 diff --git a/PSDepend/PSDependScripts/WindowsRSAT.ps1 b/PSDepend/PSDependScripts/WindowsRSAT.ps1 index 1628d14..abddd30 100644 --- a/PSDepend/PSDependScripts/WindowsRSAT.ps1 +++ b/PSDepend/PSDependScripts/WindowsRSAT.ps1 @@ -133,7 +133,7 @@ if ( $PSDependAction -contains 'Test' -and $PSDependAction.count -eq 1) { if ($PSDependAction -contains 'Install') { - if (-not ((New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))) { + if (-not (Test-Administrator)) { throw "Must be an admin to install RSAT modules" } diff --git a/PSDepend/Private/Test-Administrator.ps1 b/PSDepend/Private/Test-Administrator.ps1 new file mode 100644 index 0000000..952b465 --- /dev/null +++ b/PSDepend/Private/Test-Administrator.ps1 @@ -0,0 +1,9 @@ +function Test-Administrator { + [CmdletBinding()] + [OutputType([bool])] + param() + + ([Security.Principal.WindowsPrincipal]::new( + [Security.Principal.WindowsIdentity]::GetCurrent() + )).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} From 2df5fb32e10d83019babc9b08ce29a96bc6ade98 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 15 May 2026 23:28:38 -0400 Subject: [PATCH 6/8] fix(WindowsRSAT): handle unknown module name under strict mode The original guard used $RSAT_MODULE_MAP[$ModuleName][$Type] to detect an unknown module and relied on the chain returning $null. Under PowerShell strict mode (Set-StrictMode -Version 2+), indexing into a $null array throws "Cannot index into a null array" before the explicit throw is reached -- making the "Unknown Module" error unreachable whenever the module name was missing from the map entirely. Adds a ContainsKey guard before the index chain so the explicit error message is reached under strict and non-strict modes alike. Surfaced by the new "Throws when the module name is not in the mapping table" test. Co-Authored-By: Claude Opus 4.7 (1M context) --- PSDepend/PSDependScripts/WindowsRSAT.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PSDepend/PSDependScripts/WindowsRSAT.ps1 b/PSDepend/PSDependScripts/WindowsRSAT.ps1 index abddd30..52420fb 100644 --- a/PSDepend/PSDependScripts/WindowsRSAT.ps1 +++ b/PSDepend/PSDependScripts/WindowsRSAT.ps1 @@ -144,7 +144,11 @@ if ($PSDependAction -contains 'Install') { $Type = 'WindowsCapability' } - if ($null -eq $RSAT_MODULE_MAP[$ModuleName][$type]) { + if (-not $RSAT_MODULE_MAP.ContainsKey($ModuleName)) { + throw "Unknown Module $ModuleName" + } + + if ($null -eq $RSAT_MODULE_MAP[$ModuleName][$type]) { throw "Unknown Module $ModuleName" } From adc3f6a5554f867e2f64d1ccc5012cc02e9550f7 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 15 May 2026 23:30:09 -0400 Subject: [PATCH 7/8] test(WindowsRSAT): add Pester tests for dispatch and mapping Adds Tests/WindowsRSAT.Type.Tests.ps1 with 12 tests covering: - Test-only action: returns $false when missing, $true when available - Install on Server (ProductType=3): dispatches to Install-WindowsFeature with the correct mapped name for ActiveDirectory (baseline) plus BitLocker, Hyper-V, and GroupPolicy/GPMC (the three Server-side fixes from the preceding commits) - Install on Workstation (ProductType=1): dispatches to Add-WindowsCapability with the correct mapped name - Unknown module name throws "Unknown Module" - Install gated by admin check: Test-Administrator -> $false throws - Test, Install short-circuits when the module is already available Test-PSDependTypeSupportedHere skips the entire Describe on non-Windows. The script's admin check is mocked via Test-Administrator (extracted in the preceding refactor commit) so all 12 tests run regardless of the test runner's elevation status. Install-WindowsFeature ships only on Windows Server (ServerManager module). The BeforeAll injects stub functions into the PSDepend module scope when the real cmdlets are missing, so Pester's Mock has a command to attach to on hosts that don't ship them (e.g. Win 11 client when testing the Server dispatch path locally). Verified locally on Win 11 (non-admin): 12 passed, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- Tests/WindowsRSAT.Type.Tests.ps1 | 155 +++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 Tests/WindowsRSAT.Type.Tests.ps1 diff --git a/Tests/WindowsRSAT.Type.Tests.ps1 b/Tests/WindowsRSAT.Type.Tests.ps1 new file mode 100644 index 0000000..155aaa7 --- /dev/null +++ b/Tests/WindowsRSAT.Type.Tests.ps1 @@ -0,0 +1,155 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeDiscovery { + Import-Module (Join-Path $PSScriptRoot 'Shared/TestHelpers.psm1') -Force + $script:SkipUnsupported = -not (Test-PSDependTypeSupportedHere -DependencyType 'WindowsRSAT') +} + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force + + Import-Module (Join-Path $PSScriptRoot 'Shared/TestHelpers.psm1') -Force + + $script:ScriptPath = Join-Path $env:BHProjectPath 'PSDepend/PSDependScripts/WindowsRSAT.ps1' + + # Install-WindowsFeature ships only on Windows Server (ServerManager module), + # and Add-WindowsCapability requires Windows. Inject stubs into the PSDepend + # module scope so Mock has a command to attach to on hosts that don't ship + # the real cmdlets (e.g. Windows client when testing the Server dispatch path). + InModuleScope PSDepend { + if (-not (Get-Command -Name Install-WindowsFeature -ErrorAction SilentlyContinue)) { + function script:Install-WindowsFeature { [CmdletBinding()] param([string]$Name) } + } + if (-not (Get-Command -Name Add-WindowsCapability -ErrorAction SilentlyContinue)) { + function script:Add-WindowsCapability { [CmdletBinding()] param([switch]$Online, [string]$Name) } + } + } +} + +Describe 'WindowsRSAT script' -Tag 'WindowsOnly' -Skip:$SkipUnsupported { + + BeforeAll { + InModuleScope PSDepend { + Mock Get-Module { } -ParameterFilter { $ListAvailable } + Mock Install-WindowsFeature { } + Mock Add-WindowsCapability { } + Mock Get-CimInstance { [PSCustomObject]@{ ProductType = 3 } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } + Mock Import-PSDependModule { } + Mock Test-Administrator { $true } + } + } + + Context 'PSDependAction = Test only' { + It 'Returns $false when the module is not installed' { + $dep = New-PSDependFixture -DependencyName 'ActiveDirectory' -DependencyType 'WindowsRSAT' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Test + } + $result | Should -Be $false + Should -Invoke -CommandName Install-WindowsFeature -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName Add-WindowsCapability -ModuleName PSDepend -Times 0 + } + + It 'Returns $true when the module is already available' { + InModuleScope PSDepend { + Mock Get-Module { [PSCustomObject]@{ Name = 'ActiveDirectory' } } -ParameterFilter { $ListAvailable } + } + $dep = New-PSDependFixture -DependencyName 'ActiveDirectory' -DependencyType 'WindowsRSAT' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Test + } + $result | Should -Be $true + Should -Invoke -CommandName Install-WindowsFeature -ModuleName PSDepend -Times 0 + } + } + + Context 'PSDependAction = Install on Server' { + + It 'Dispatches to Install-WindowsFeature with the mapped name ( -> )' -TestCases @( + @{ ModuleName = 'ActiveDirectory'; Feature = 'RSAT-AD-Powershell' } + @{ ModuleName = 'BitLocker'; Feature = 'RSAT-Feature-Tools-BitLocker-RemoteAdminTool' } + @{ ModuleName = 'Hyper-V'; Feature = 'RSAT-Hyper-V-Tools' } + @{ ModuleName = 'GroupPolicy'; Feature = 'GPMC' } + ) { + param($ModuleName, $Feature) + + $dep = New-PSDependFixture -DependencyName $ModuleName -DependencyType 'WindowsRSAT' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Install + } + Should -Invoke -CommandName Install-WindowsFeature -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $Name -eq $Feature + } + Should -Invoke -CommandName Add-WindowsCapability -ModuleName PSDepend -Times 0 + } + + It 'Throws when the module name is not in the mapping table' { + $dep = New-PSDependFixture -DependencyName 'NotARealModule' -DependencyType 'WindowsRSAT' + { + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Install + } + } | Should -Throw '*Unknown Module*' + } + } + + Context 'PSDependAction = Install on Workstation' { + + BeforeAll { + InModuleScope PSDepend { + Mock Get-CimInstance { [PSCustomObject]@{ ProductType = 1 } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } + } + } + + It 'Dispatches to Add-WindowsCapability with the mapped name ( -> )' -TestCases @( + @{ ModuleName = 'ActiveDirectory'; Capability = 'Rsat.ActiveDirectory.DS-LDS.Tools' } + @{ ModuleName = 'BitLocker'; Capability = 'Rsat.BitLocker.Recovery.Tools' } + @{ ModuleName = 'GroupPolicy'; Capability = 'Rsat.GroupPolicy.Management.Tools' } + ) { + param($ModuleName, $Capability) + + $dep = New-PSDependFixture -DependencyName $ModuleName -DependencyType 'WindowsRSAT' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Install + } + Should -Invoke -CommandName Add-WindowsCapability -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $Name -eq $Capability + } + Should -Invoke -CommandName Install-WindowsFeature -ModuleName PSDepend -Times 0 + } + } + + Context 'PSDependAction = Install gated by admin check' { + It 'Throws when Test-Administrator returns $false' { + InModuleScope PSDepend { + Mock Test-Administrator { $false } + } + $dep = New-PSDependFixture -DependencyName 'ActiveDirectory' -DependencyType 'WindowsRSAT' + { + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Install + } + } | Should -Throw '*admin*' + Should -Invoke -CommandName Install-WindowsFeature -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName Add-WindowsCapability -ModuleName PSDepend -Times 0 + } + } + + Context 'PSDependAction = Test, Install short-circuits when installed' { + It 'Skips Install when the module is already available' { + InModuleScope PSDepend { + Mock Get-Module { [PSCustomObject]@{ Name = 'ActiveDirectory' } } -ParameterFilter { $ListAvailable } + } + $dep = New-PSDependFixture -DependencyName 'ActiveDirectory' -DependencyType 'WindowsRSAT' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Test, Install + } + Should -Invoke -CommandName Install-WindowsFeature -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName Add-WindowsCapability -ModuleName PSDepend -Times 0 + } + } +} From cf31ed456131c3bd51b8f7f3e67d3b8a76ecdaff Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 15 May 2026 23:41:53 -0400 Subject: [PATCH 8/8] test(WindowsRSAT): always stub install cmdlets for reliable mocking on PS 5.1 The previous BeforeAll only injected stub functions when Get-Command found no real Install-WindowsFeature / Add-WindowsCapability. On the Windows PowerShell 5.1 CI matrix entry, ServerManager auto-loads on Server 2022 and the real Install-WindowsFeature is present, so the stub was skipped. Pester then mocked the CDXML cmdlet, but the mock silently did not intercept calls from the script under test -- all four Server-dispatch tests reported "was called 0 times" while the rest passed. (PS 7 matrix entries are unaffected since ServerManager does not load on PowerShell Core, so the stub path was always taken.) PowerShell resolves functions before cmdlets in the same scope, so injecting the stub unconditionally lets it take precedence over the real cmdlet, and Pester reliably mocks the function across all matrix entries. Verified locally on Win 11 in both PowerShell 7 and Windows PowerShell 5.1: 12 passed, 0 failed, 0 skipped on both. Co-Authored-By: Claude Opus 4.7 (1M context) --- Tests/WindowsRSAT.Type.Tests.ps1 | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/Tests/WindowsRSAT.Type.Tests.ps1 b/Tests/WindowsRSAT.Type.Tests.ps1 index 155aaa7..873fd58 100644 --- a/Tests/WindowsRSAT.Type.Tests.ps1 +++ b/Tests/WindowsRSAT.Type.Tests.ps1 @@ -16,17 +16,18 @@ BeforeAll { $script:ScriptPath = Join-Path $env:BHProjectPath 'PSDepend/PSDependScripts/WindowsRSAT.ps1' - # Install-WindowsFeature ships only on Windows Server (ServerManager module), - # and Add-WindowsCapability requires Windows. Inject stubs into the PSDepend - # module scope so Mock has a command to attach to on hosts that don't ship - # the real cmdlets (e.g. Windows client when testing the Server dispatch path). + # Inject stub functions for the install-side cmdlets into the PSDepend + # module scope so Pester's Mock attaches to a regular PowerShell function + # rather than to the underlying CDXML/binary cmdlets. PowerShell resolves + # functions before cmdlets in the same scope, so on hosts where the real + # cmdlets exist (Windows Server PS 5.1 with ServerManager, Windows client + # with DISM) the stub still wins. Mocking the real CDXML cmdlets has been + # observed to silently not intercept on Windows PowerShell 5.1 -- the + # stub-function approach makes mocking work consistently across PS 5.1, + # PS 7, and all platforms. InModuleScope PSDepend { - if (-not (Get-Command -Name Install-WindowsFeature -ErrorAction SilentlyContinue)) { - function script:Install-WindowsFeature { [CmdletBinding()] param([string]$Name) } - } - if (-not (Get-Command -Name Add-WindowsCapability -ErrorAction SilentlyContinue)) { - function script:Add-WindowsCapability { [CmdletBinding()] param([switch]$Online, [string]$Name) } - } + function script:Install-WindowsFeature { [CmdletBinding()] param([string]$Name) } + function script:Add-WindowsCapability { [CmdletBinding()] param([switch]$Online, [string]$Name) } } }