From faadd61ae0562252603755cd58b62550b1fa2342 Mon Sep 17 00:00:00 2001 From: Justin Ainsworth Date: Tue, 14 Dec 2021 17:50:08 -0800 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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) } } } From 1d70b026f2f4fc6d0cc7ed8175a4b64c5da73667 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Sun, 24 May 2026 12:16:55 -0400 Subject: [PATCH 9/9] fix(WindowsRSAT): resolve canonical capability identity and clarify errors Review follow-ups for PR #136: - Workstation path resolves the exact capability identity via Get-WindowsCapability prefix lookup before calling Add-WindowsCapability. DISM does accept the short base name on Win 11 25H2 (Copilot's "will fail to find the capability" did not reproduce), but the version suffix varies by build, so passing the canonical name is build-independent. - Distinguish "unknown module" from "in the table but no install path for this OS" (e.g. server-only modules like Hyper-V/ADRMS on a Workstation) with a clear, distinct error message. - Make the elevation requirement actionable in the throw, and document in comment-based help that Install needs admin while Test does not. - Normalize DNS capability casing to the live catalog (Rsat.Dns.Tools), fix Get-CimInstance casing, strip trailing whitespace. Verified end-to-end via Invoke-PSDepend on real OSes: Server 2022 / Windows PowerShell 5.1 (Install-WindowsFeature) and Windows 11 25H2 (Add-WindowsCapability) both install + import + Test=True. Unit tests: 15 passing on PS7 and Windows PS 5.1. Co-Authored-By: Claude Opus 4.7 (1M context) --- PSDepend/PSDependScripts/WindowsRSAT.ps1 | 51 ++++++++++++++++-------- Tests/WindowsRSAT.Type.Tests.ps1 | 35 ++++++++++++++-- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/PSDepend/PSDependScripts/WindowsRSAT.ps1 b/PSDepend/PSDependScripts/WindowsRSAT.ps1 index 52420fb..f59d271 100644 --- a/PSDepend/PSDependScripts/WindowsRSAT.ps1 +++ b/PSDepend/PSDependScripts/WindowsRSAT.ps1 @@ -5,8 +5,13 @@ .DESCRIPTION Installs a RSAT Module in Windows. + The Install action requires an elevated (administrator) session, on both + Workstation (Add-WindowsCapability) and Server (Install-WindowsFeature). + The Test action does not require elevation. If the module is already + present, all actions short-circuit and succeed without elevation. + Relevant Dependency metadata: - Name: The name for the module to install + Name: The name for the module to install .PARAMETER PSDependAction Test, Install, or Import the module. Defaults to Install @@ -18,8 +23,8 @@ .EXAMPLE @{ ActiveDirectory = @{ - DependencyType = 'WindowsRSAT' - Name = 'ActiveDirectory' + DependencyType = 'WindowsRSAT' + Name = 'ActiveDirectory' } } #> @@ -76,11 +81,11 @@ $RSAT_MODULE_MAP = @{ } 'DNSClient' = @{ 'WindowsFeature' = 'RSAT-DNS-Server' - 'WindowsCapability' = 'rsat.dns.tools' + 'WindowsCapability' = 'Rsat.Dns.Tools' } 'DNSServer' = @{ 'WindowsFeature' = 'RSAT-DNS-Server' - 'WindowsCapability' = 'rsat.dns.tools' + 'WindowsCapability' = 'Rsat.Dns.Tools' } 'FailoverClusters' = @{ 'WindowsFeature' = 'RSAT-Clustering-PowerShell' @@ -132,33 +137,47 @@ if ( $PSDependAction -contains 'Test' -and $PSDependAction.count -eq 1) { } if ($PSDependAction -contains 'Install') { - + if (-not (Test-Administrator)) { - throw "Must be an admin to install RSAT modules" + throw "Installing RSAT module '$ModuleName' requires an elevated session. Re-run from a PowerShell started with 'Run as administrator'." } #Server $Type = 'WindowsFeature' - if ((get-CimInstance -ClassName Win32_OperatingSystem).ProductType -eq 1) { + if ((Get-CimInstance -ClassName Win32_OperatingSystem).ProductType -eq 1) { # Workstation $Type = 'WindowsCapability' } - + if (-not $RSAT_MODULE_MAP.ContainsKey($ModuleName)) { - throw "Unknown Module $ModuleName" + throw "Unknown module '$ModuleName'. No RSAT mapping is defined for it." } - if ($null -eq $RSAT_MODULE_MAP[$ModuleName][$type]) { - throw "Unknown Module $ModuleName" + $mapping = $RSAT_MODULE_MAP[$ModuleName] + if (-not $mapping.ContainsKey($Type) -or [string]::IsNullOrEmpty($mapping[$Type])) { + # In the table, but no entry for this OS install path. Most commonly a + # module that ships only as a Server feature (e.g. Hyper-V, ADRMS) and + # has no equivalent Windows capability on a Workstation. + throw "Module '$ModuleName' is not available via $Type on this system (it may be server-only)." } - + if ($Type -eq 'WindowsFeature') { - $null = install-windowsfeature -name $RSAT_MODULE_MAP[$ModuleName][$Type] + $null = Install-WindowsFeature -Name $mapping[$Type] } else { - $null = Add-WindowsCapability -Online -Name $RSAT_MODULE_MAP[$ModuleName][$Type] + # Resolve the exact capability identity (e.g. + # 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0') from the stable short + # name in the map. DISM does accept the short base name directly, but + # that relies on undocumented prefix matching and the version suffix + # varies by Windows build -- so look it up and pass the canonical name. + $capabilityName = $mapping[$Type] + $capability = Get-WindowsCapability -Online -Name "$capabilityName*" | Select-Object -First 1 + if (-not $capability) { + throw "No Windows capability matching '$capabilityName' was found on this system." + } + $null = Add-WindowsCapability -Online -Name $capability.Name } } # Conditional import -Import-PSDependModule -Name $ModuleName -Action $PSDependAction \ No newline at end of file +Import-PSDependModule -Name $ModuleName -Action $PSDependAction diff --git a/Tests/WindowsRSAT.Type.Tests.ps1 b/Tests/WindowsRSAT.Type.Tests.ps1 index 873fd58..7f913b5 100644 --- a/Tests/WindowsRSAT.Type.Tests.ps1 +++ b/Tests/WindowsRSAT.Type.Tests.ps1 @@ -28,6 +28,7 @@ BeforeAll { InModuleScope PSDepend { function script:Install-WindowsFeature { [CmdletBinding()] param([string]$Name) } function script:Add-WindowsCapability { [CmdletBinding()] param([switch]$Online, [string]$Name) } + function script:Get-WindowsCapability { [CmdletBinding()] param([switch]$Online, [string]$Name) } } } @@ -38,6 +39,10 @@ Describe 'WindowsRSAT script' -Tag 'WindowsOnly' -Skip:$SkipUnsupported { Mock Get-Module { } -ParameterFilter { $ListAvailable } Mock Install-WindowsFeature { } Mock Add-WindowsCapability { } + # Mirror the real resolver: the script queries with a 'Rsat.X*' + # prefix and the live system returns the full version-suffixed + # identity, which is what gets passed to Add-WindowsCapability. + Mock Get-WindowsCapability { [PSCustomObject]@{ Name = ($Name -replace '\*$', '') + '~~~~0.0.1.0'; State = 'NotPresent' } } Mock Get-CimInstance { [PSCustomObject]@{ ProductType = 3 } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } Mock Import-PSDependModule { } Mock Test-Administrator { $true } @@ -94,7 +99,7 @@ Describe 'WindowsRSAT script' -Tag 'WindowsOnly' -Skip:$SkipUnsupported { InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Install } - } | Should -Throw '*Unknown Module*' + } | Should -Throw '*No RSAT mapping*' } } @@ -106,10 +111,11 @@ Describe 'WindowsRSAT script' -Tag 'WindowsOnly' -Skip:$SkipUnsupported { } } - It 'Dispatches to Add-WindowsCapability with the mapped name ( -> )' -TestCases @( + It 'Resolves and dispatches to Add-WindowsCapability with the full identity ( -> ~~~~*)' -TestCases @( @{ ModuleName = 'ActiveDirectory'; Capability = 'Rsat.ActiveDirectory.DS-LDS.Tools' } @{ ModuleName = 'BitLocker'; Capability = 'Rsat.BitLocker.Recovery.Tools' } @{ ModuleName = 'GroupPolicy'; Capability = 'Rsat.GroupPolicy.Management.Tools' } + @{ ModuleName = 'DNSServer'; Capability = 'Rsat.Dns.Tools' } ) { param($ModuleName, $Capability) @@ -117,11 +123,32 @@ Describe 'WindowsRSAT script' -Tag 'WindowsOnly' -Skip:$SkipUnsupported { InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Install } + # Script must look the capability up by prefix... + Should -Invoke -CommandName Get-WindowsCapability -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $Name -eq "$Capability*" + } + # ...and install the full version-suffixed identity it returns, not the short name. Should -Invoke -CommandName Add-WindowsCapability -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { - $Name -eq $Capability + $Name -eq "$Capability~~~~0.0.1.0" } Should -Invoke -CommandName Install-WindowsFeature -ModuleName PSDepend -Times 0 } + + It 'Throws a clear server-only error for modules with no capability mapping ()' -TestCases @( + @{ ModuleName = 'Hyper-V' } + @{ ModuleName = 'ADRMS' } + ) { + param($ModuleName) + + $dep = New-PSDependFixture -DependencyName $ModuleName -DependencyType 'WindowsRSAT' + { + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Install + } + } | Should -Throw '*not available*server-only*' + Should -Invoke -CommandName Add-WindowsCapability -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName Install-WindowsFeature -ModuleName PSDepend -Times 0 + } } Context 'PSDependAction = Install gated by admin check' { @@ -134,7 +161,7 @@ Describe 'WindowsRSAT script' -Tag 'WindowsOnly' -Skip:$SkipUnsupported { InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Install } - } | Should -Throw '*admin*' + } | Should -Throw '*elevated session*' Should -Invoke -CommandName Install-WindowsFeature -ModuleName PSDepend -Times 0 Should -Invoke -CommandName Add-WindowsCapability -ModuleName PSDepend -Times 0 }