Improve registry backup safety and add optional backup skipping (#710)

This commit is contained in:
Jeffrey
2026-07-25 20:05:49 +02:00
committed by GitHub
parent 68cacfce89
commit 32cedaf65d
30 changed files with 1603 additions and 43 deletions
+33
View File
@@ -142,3 +142,36 @@ Describe 'Restore-RegistryKeySnapshot - validation' {
Should -Throw $ExpectedError
}
}
Describe 'Invoke-RegistryDeleteValueOperation' {
It 'deletes the default value and always closes an opened registry key' {
$calls = [System.Collections.Generic.List[string]]::new()
$key = [PSCustomObject]@{}
$key | Add-Member -MemberType ScriptMethod -Name DeleteValue -Value { param($Name, $ThrowOnMissing) $calls.Add("delete:${Name}:$ThrowOnMissing") }
$key | Add-Member -MemberType ScriptMethod -Name Close -Value { $calls.Add('close') }
Invoke-RegistryDeleteValueOperation -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\Software\Test'; ValueName = $null }) -KeyInfo ([PSCustomObject]@{ Key = $key })
$calls | Should -Be @('delete::False', 'close')
}
}
Describe 'Invoke-RegistrySetValueOperation' {
It 'throws for an unavailable set-value key before attempting conversion' {
Mock Convert-RegOperationToValueKind { throw 'conversion should not run' }
{ Invoke-RegistrySetValueOperation -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\Software\Test' }) -KeyInfo ([PSCustomObject]@{ Key = $null }) } |
Should -Throw "Unable to open or create registry key*"
Should -Invoke Convert-RegOperationToValueKind -Times 0 -Exactly
}
}
Describe 'Write-RegistryOperationAccessDeniedWarning' {
It 'formats the default registry value in access-denied warnings' {
Mock Write-Warning {}
Write-RegistryOperationAccessDeniedWarning -Operation ([PSCustomObject]@{ OperationType = 'DeleteValue'; KeyPath = 'HKCU\Software\Test'; ValueName = $null }) -ExceptionMessage 'denied'
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "value '\(Default\)'" -and $Message -match 'denied' }
}
}
+105
View File
@@ -28,4 +28,109 @@ Describe 'Get-RebootFeatureLabels' {
$result | Should -Contain 'Undo feature'
$result | Should -Not -Contain 'No reboot'
}
It 'uses the regular label for a forward-only selection' {
$script:Params = @{ ApplyFeature = $true }
$script:UndoParams = @{}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Apply feature'
}
It 'falls back to the regular label when an undo selection has no undo label' {
$script:Params = @{}
$script:UndoParams = @{ ApplyFeature = $true }
$script:Features.ApplyFeature.UndoLabel = $null
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Apply feature'
}
It 'falls back to the regular label when the feature has no UndoLabel property' {
$script:Params = @{}
$script:UndoParams = @{ MissingUndoLabelFeature = $true }
$script:Features.MissingUndoLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Feature without undo label'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Feature without undo label'
}
It 'returns no labels when there are no selected parameters' {
$script:Params = @{}
$script:UndoParams = @{}
@(Get-RebootFeatureLabels).Count | Should -Be 0
}
It 'keeps one label for each distinct reboot feature when their labels match' {
$script:Params = @{
FirstMatchingLabelFeature = $true
SecondMatchingLabelFeature = $true
}
$script:UndoParams = @{}
$script:Features.FirstMatchingLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Shared label'
UndoLabel = 'Undo first shared label'
}
$script:Features.SecondMatchingLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Shared label'
UndoLabel = 'Undo second shared label'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 2
@($result | Where-Object { $_ -eq 'Shared label' }).Count | Should -Be 2
}
It 'accepts truthy reboot flags' {
$script:Params = @{
StringRebootFeature = $true
NumericRebootFeature = $true
}
$script:UndoParams = @{}
$script:Features.StringRebootFeature = [PSCustomObject]@{
RequiresReboot = 'true'
Label = 'String reboot'
UndoLabel = 'Undo string reboot'
}
$script:Features.NumericRebootFeature = [PSCustomObject]@{
RequiresReboot = 1
Label = 'Numeric reboot'
UndoLabel = 'Undo numeric reboot'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 2
$result | Should -Contain 'String reboot'
$result | Should -Contain 'Numeric reboot'
}
It 'excludes unknown, non-reboot, and blank-label selections' {
$script:Params = @{
UnknownFeature = $true
NoRebootFeature = $true
BlankLabelFeature = $true
}
$script:UndoParams = @{}
$script:Features.BlankLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = ' '
UndoLabel = $null
}
@(Get-RebootFeatureLabels).Count | Should -Be 0
}
}
+99
View File
@@ -0,0 +1,99 @@
BeforeAll {
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
function New-WingetTestJob { Microsoft.PowerShell.Core\Start-Job -ScriptBlock {} }
. (Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Get-WingetInstalledApps.ps1')
}
Describe 'Get-WingetInstalledApps' {
BeforeEach {
$script:WingetInstalled = $true
$script:WingetTestJob = $null
Mock Remove-Job {}
}
AfterEach {
if ($null -ne $script:WingetTestJob) {
Microsoft.PowerShell.Core\Remove-Job -Job $script:WingetTestJob -Force -ErrorAction SilentlyContinue
}
}
It 'returns null without starting a job when winget is unavailable' {
$script:WingetInstalled = $false
Mock Start-Job { throw 'Winget should not be started.' }
Get-WingetInstalledApps | Should -BeNullOrEmpty
Should -Invoke Start-Job -Times 0 -Exactly
}
It 'delegates to the non-blocking runner when requested' {
Mock Invoke-NonBlocking { @([PSCustomObject]@{ Name = 'App'; Id = 'Contoso.App' }) }
$result = @(Get-WingetInstalledApps -NonBlocking)
$result | Should -HaveCount 1
$result[0].Id | Should -Be 'Contoso.App'
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly
}
It 'returns an empty collection when winget output has no table separator' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job { @('Name Id', 'No parseable table') }
@(Get-WingetInstalledApps) | Should -BeNullOrEmpty
Should -Invoke Remove-Job -Times 1 -Exactly -ParameterFilter { -not $Force }
}
It 'parses valid rows and skips malformed rows' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job {
@(
'Name Id Version'
'-----------------------------------------------------------------------'
'Contoso App Contoso.App 1.0'
'malformed-row'
'Fabrikam Tools Fabrikam.Tools 2.0'
)
}
$result = @(Get-WingetInstalledApps)
$result | Should -HaveCount 2
$result.Id | Should -Be @('Contoso.App', 'Fabrikam.Tools')
}
It 'parses localized headers and long Unicode display names' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job {
@(
'Naam Id Versie'
'-----------------------------------------------------------------------'
'Contoso hulpmiddel voor gegevens Contoso.DataTools 2026.07'
'Fabrikam Café Fabrikam.Cafe 1.0'
)
}
$result = @(Get-WingetInstalledApps)
$result | Should -HaveCount 2
$result[0].Name | Should -Be 'Contoso hulpmiddel voor gegevens'
$result.Id | Should -Be @('Contoso.DataTools', 'Fabrikam.Cafe')
}
It 'returns null and force-removes the job when winget times out' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $null }
Get-WingetInstalledApps | Should -BeNullOrEmpty
Should -Invoke Remove-Job -Times 1 -Exactly -ParameterFilter { $Force }
}
}
+8
View File
@@ -3,6 +3,7 @@ BeforeAll {
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Add-Parameter.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Import-ConfigToParams.ps1')
$script:ConfigFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading\ExportedConfig.WithSettings.json'
$script:SkipRegistryBackupFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading\ExportedConfig.SkipRegistryBackup.json'
}
Describe 'Import-ConfigToParams' {
@@ -31,8 +32,15 @@ Describe 'Import-ConfigToParams' {
$script:Params[$featureId] | Should -BeTrue
}
$script:Params['CreateRestorePoint'] | Should -BeTrue
$script:Params.ContainsKey('SkipRegistryBackup') | Should -BeFalse
$script:Params['SkipExplorerRestart'] | Should -BeTrue
$script:Params.ContainsKey('User') | Should -BeFalse
$script:Params.ContainsKey('AppRemovalTarget') | Should -BeFalse
}
It 'imports SkipRegistryBackup when deployment settings request it' {
Import-ConfigToParams -ConfigPath $script:SkipRegistryBackupFixturePath -CurrentBuild 22631 | Out-Null
$script:Params['SkipRegistryBackup'] | Should -BeTrue
}
}
+53 -7
View File
@@ -5,7 +5,7 @@ BeforeAll {
function Enable-TelemetryScheduledTasks {}
function Generate-AppsList { @() }
function Get-FriendlyTargetUserName { 'current user' }
function EnableStoreSearchSuggestionsForAllUsers {}
function Set-StoreSearchSuggestionsEnabledForAllUsers {}
function Set-StoreSearchSuggestionsEnabled { param($StoreAppsDatabase) }
function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' }
function Get-UserName { 'Alice' }
@@ -16,7 +16,7 @@ BeforeAll {
function Get-StartMenuBinPathForUser { param($UserName) 'start.bin' }
function Replace-StartMenu { param($startMenuBinFile, $startMenuTemplate) }
function Replace-StartMenuForAllUsers { param($startMenuTemplate) }
function DisableStoreSearchSuggestionsForAllUsers {}
function Set-StoreSearchSuggestionsDisabledForAllUsers {}
function Set-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) }
. (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-Changes.ps1')
@@ -70,11 +70,11 @@ Describe 'Invoke-FeatureApply' {
Mock Get-UserName { 'Alice' }
Mock Replace-StartMenu {}
Mock Replace-StartMenuForAllUsers {}
Mock DisableStoreSearchSuggestionsForAllUsers {}
Mock Set-StoreSearchSuggestionsDisabledForAllUsers {}
Mock Set-StoreSearchSuggestionsDisabled {}
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
Mock Get-Process { @() }
Mock Stop-Process {}
Mock Stop-Process { param($InputObject) }
Mock Write-Host {}
}
@@ -132,6 +132,15 @@ Describe 'Invoke-FeatureApply' {
Should -Invoke Stop-Process -Times 0 -Exactly
}
It 'stops widget processes before removing widget packages' {
$widget = [PSCustomObject]@{ Name = 'WidgetService' }
Mock Get-Process { $widget }
Invoke-FeatureApply -FeatureId 'DisableWidgets'
Should -Invoke Stop-Process -Times 1 -Exactly
}
It 'stops widget processes without a confirmation prompt' {
Mock Get-Process { [PSCustomObject]@{ Name = 'Widgets' } }
@@ -173,7 +182,15 @@ Describe 'Invoke-FeatureApply' {
$script:Params = @{ Sysprep = $true }
Invoke-FeatureApply -FeatureId 'DisableStoreSearchSuggestions'
Should -Invoke DisableStoreSearchSuggestionsForAllUsers -Times 1 -Exactly
Should -Invoke Set-StoreSearchSuggestionsDisabledForAllUsers -Times 1 -Exactly
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 0 -Exactly
}
It 'does not update Store search suggestions when the current user database cannot be resolved' {
Mock Get-StoreAppsDatabasePathForUser { $null }
Invoke-FeatureApply -FeatureId 'DisableStoreSearchSuggestions'
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 0 -Exactly
}
}
@@ -235,6 +252,15 @@ Describe 'Invoke-UndoFeatures' {
Should -Invoke Import-RegistryFile -Times 0 -Exactly
Should -Invoke Invoke-FeatureUndo -Times 2 -Exactly
}
It 'stops before undoing when cancellation is requested' {
$script:CancelRequested = $true
Invoke-UndoFeatures -FeatureIds @('RegistryUndo') -StartStep 1 -TotalSteps 1
Should -Invoke Import-RegistryFile -Times 0 -Exactly
Should -Invoke Invoke-FeatureUndo -Times 0 -Exactly
}
}
Describe 'Invoke-FeatureUndo' {
@@ -246,7 +272,7 @@ Describe 'Invoke-FeatureUndo' {
DisableTelemetry = [PSCustomObject]@{}
DisableStoreSearchSuggestions = [PSCustomObject]@{}
}
Mock EnableStoreSearchSuggestionsForAllUsers {}
Mock Set-StoreSearchSuggestionsEnabledForAllUsers {}
Mock Set-StoreSearchSuggestionsEnabled {}
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
Mock Get-UserName { 'Alice' }
@@ -261,7 +287,7 @@ Describe 'Invoke-FeatureUndo' {
) {
$script:Params = $Params
Invoke-FeatureUndo -FeatureId 'DisableStoreSearchSuggestions'
Should -Invoke EnableStoreSearchSuggestionsForAllUsers -Times $AllUsers -Exactly
Should -Invoke Set-StoreSearchSuggestionsEnabledForAllUsers -Times $AllUsers -Exactly
Should -Invoke Set-StoreSearchSuggestionsEnabled -Times $CurrentUser -Exactly -ParameterFilter { $StoreAppsDatabase -eq 'store.db' }
}
@@ -323,6 +349,16 @@ Describe 'Invoke-AllChanges' {
Should -Invoke Invoke-SystemRestorePoint -Times 0 -Exactly
}
It 'does not create a registry backup when explicitly skipped' {
$script:Params['SkipRegistryBackup'] = $true
Invoke-AllChanges
Should -Invoke New-RegistrySettingsBackup -Times 0 -Exactly
Should -Invoke Invoke-ApplyFeatures -Times 1 -Exactly
Should -Invoke Invoke-UndoFeatures -Times 1 -Exactly
}
It 'does not run when cancellation was already requested' {
$script:CancelRequested = $true
Invoke-AllChanges
@@ -363,4 +399,14 @@ Describe 'Invoke-AllChanges' {
Invoke-AllChanges
$script:order | Should -Be @('restore-point', 'apply')
}
It 'reports registry import failures after all requested work completes' {
$script:Params = @{ CustomApply = $true }
$script:UndoParams = @{}
Mock Invoke-ApplyFeatures { $script:RegistryImportFailures = 2 }
Invoke-AllChanges
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -match '2 registry import change' }
}
}
+56
View File
@@ -0,0 +1,56 @@
BeforeAll {
function Invoke-DoEvents {}
. (Join-Path $PSScriptRoot '..\Scripts\Threading\Invoke-NonBlocking.ps1')
}
Describe 'Invoke-NonBlocking' {
BeforeEach { $script:GuiWindow = $null }
It 'runs directly in CLI mode without a timeout and preserves a scalar result' {
Invoke-NonBlocking -ScriptBlock { param($Value) "result-$Value" } -ArgumentList 'one' | Should -Be 'result-one'
}
It 'runs directly in CLI mode without a timeout and preserves multiple results' {
$result = @(Invoke-NonBlocking -ScriptBlock { 1; 2; 3 })
$result | Should -Be @(1, 2, 3)
}
It 'stops a timed CLI operation and reports a timeout' {
{ Invoke-NonBlocking -ScriptBlock { Start-Sleep -Seconds 3 } -TimeoutSeconds 1 } |
Should -Throw 'Operation timed out after 1 seconds'
}
It 'stops a timed GUI operation and reports a timeout' {
$script:GuiWindow = [PSCustomObject]@{}
Mock Invoke-DoEvents {}
{ Invoke-NonBlocking -ScriptBlock { Start-Sleep -Seconds 3 } -TimeoutSeconds 1 } |
Should -Throw 'Operation timed out after 1 seconds'
}
It 'uses a runspace and pumps UI events when a GUI window is present' {
$script:GuiWindow = [PSCustomObject]@{}
Mock Invoke-DoEvents {}
$result = Invoke-NonBlocking -ScriptBlock { 'done' }
$result | Should -Be 'done'
}
It 'surfaces non-terminating errors from a runspace' {
$script:GuiWindow = [PSCustomObject]@{}
Mock Invoke-DoEvents {}
Mock Write-Error {}
Invoke-NonBlocking -ScriptBlock { Write-Error 'runspace failure' }
Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { $ErrorRecord.Exception.Message -match 'runspace failure' }
}
It 'remains usable after a timed operation is stopped' {
{ Invoke-NonBlocking -ScriptBlock { Start-Sleep -Seconds 3 } -TimeoutSeconds 1 } |
Should -Throw 'Operation timed out after 1 seconds'
Invoke-NonBlocking -ScriptBlock { 'next operation' } | Should -Be 'next operation'
}
}
+133
View File
@@ -0,0 +1,133 @@
BeforeAll {
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList, $TimeoutSeconds) }
function Show-MessageBox { param($Message, $Title, $Button, $Icon) }
function Enable-ComputerRestore { param($Drive) }
function Get-ComputerRestorePoint {}
function Checkpoint-Computer { param($Description, $RestorePointType) }
. (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-SystemRestorePoint.ps1')
}
Describe 'Invoke-SystemRestorePoint' {
BeforeEach {
$script:GuiWindow = $null
$script:CancelRequested = $false
$script:Silent = $false
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 1 } }
Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $true; Message = 'System restore point created successfully' } }
Mock Read-Host { 'y' }
Mock Show-MessageBox { 'Yes' }
Mock Write-Host {}
}
It 'creates a restore point when System Restore is already enabled' {
Invoke-SystemRestorePoint
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $TimeoutSeconds -eq 90 }
Should -Invoke Read-Host -Times 0 -Exactly
$script:CancelRequested | Should -BeFalse
}
It 'is loaded by the main entry point' {
$entryPoint = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\Win11Debloat.ps1') -Raw
$expectedImport = [regex]::Escape('Scripts/Features/Invoke-SystemRestorePoint.ps1')
$entryPoint | Should -Match $expectedImport
Get-Command Invoke-SystemRestorePoint | Should -Not -BeNullOrEmpty
}
It 'enables disabled System Restore before creating the point in silent mode' {
$script:Silent = $true
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 0 } }
$script:nonBlockingCalls = 0
Mock Invoke-NonBlocking {
$script:nonBlockingCalls++
if ($script:nonBlockingCalls -eq 1) { return $null }
return [PSCustomObject]@{ Success = $true; Message = 'System restore point created successfully' }
}
Invoke-SystemRestorePoint
Should -Invoke Invoke-NonBlocking -Times 2 -Exactly
Should -Invoke Read-Host -Times 0 -Exactly
}
It 'enables System Restore only for the Windows system drive' {
$script:Silent = $true
$script:nonBlockingCalls = 0
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 0 } }
Mock Invoke-NonBlocking {
param($ScriptBlock)
$script:nonBlockingCalls++
if ($script:nonBlockingCalls -eq 1) {
$script:enableRestoreBlock = $ScriptBlock
return $null
}
return [PSCustomObject]@{ Success = $true; Message = 'System restore point created successfully' }
}
Mock Enable-ComputerRestore {}
Invoke-SystemRestorePoint
& $script:enableRestoreBlock
Should -Invoke Enable-ComputerRestore -Times 1 -Exactly -ParameterFilter { $Drive -eq $env:SystemDrive }
}
It 'creates only a MODIFY_SETTINGS restore point with the project description' {
$script:restorePointBlock = $null
Mock Invoke-NonBlocking {
param($ScriptBlock)
$script:restorePointBlock = $ScriptBlock
return [PSCustomObject]@{ Success = $true; Message = 'System restore point created successfully' }
}
Mock Get-ComputerRestorePoint { @() }
Mock Checkpoint-Computer {}
Invoke-SystemRestorePoint
& $script:restorePointBlock
Should -Invoke Checkpoint-Computer -Times 1 -Exactly -ParameterFilter {
$Description -eq 'Restore point created by Win11Debloat' -and $RestorePointType -eq 'MODIFY_SETTINGS'
}
}
It 'cancels when an interactive user declines enabling disabled System Restore' {
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 0 } }
Mock Read-Host { 'n' }
Invoke-SystemRestorePoint
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
$script:CancelRequested | Should -BeTrue
}
It 'cancels when restore point creation fails and the CLI user declines continuation' {
Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $false; Message = 'creation failed' } }
Mock Read-Host { 'n' }
Invoke-SystemRestorePoint
$script:CancelRequested | Should -BeTrue
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -eq 'creation failed' }
}
It 'offers the CLI continuation choice when enabling System Restore fails' {
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 0 } }
Mock Invoke-NonBlocking { throw 'enable failed' }
Mock Read-Host { 'n' }
Invoke-SystemRestorePoint
$script:CancelRequested | Should -BeTrue
}
It 'continues after a GUI failure only when the dialog confirms it' {
$script:GuiWindow = [PSCustomObject]@{}
Mock Invoke-NonBlocking { $null }
Mock Show-MessageBox { 'No' }
Invoke-SystemRestorePoint
$script:CancelRequested | Should -BeTrue
Should -Invoke Show-MessageBox -Times 1 -Exactly
}
}
@@ -0,0 +1,61 @@
BeforeAll {
function Get-RegistryBackupCapturePlans {}
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Features\Registry-BackupValidation.ps1')
}
Describe 'Test-RegistryBackupMatchesSelectedFeatures' {
BeforeEach {
$script:Features = @{
ApplyFeature = [PSCustomObject]@{ RegistryKey = 'apply.reg'; RegistryUndoKey = 'undo.reg' }
}
$script:CapturePlanCalls = [System.Collections.Generic.List[bool]]::new()
Mock Get-RegistryBackupCapturePlans {
param($SelectedRegistryFeatures, $UndoRegistryFeatures, $UseSysprepRegFiles)
$script:CapturePlanCalls.Add([bool]$UseSysprepRegFiles)
@([PSCustomObject]@{
Path = 'HKEY_CURRENT_USER\Software\Example'
IncludeSubKeys = $false
CaptureAllValues = $false
ValueNames = @('Enabled')
})
}
}
It 'derives an allow list from selected features and accepts a matching snapshot' {
$snapshot = [PSCustomObject]@{
Path = 'HKEY_CURRENT_USER\Software\Example'
Values = @([PSCustomObject]@{ Name = 'Enabled'; Exists = $true; Kind = 'DWord'; Data = 1 })
SubKeys = @()
}
$errors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @('ApplyFeature') -SelectedUndoFeatureIds @() -Target 'CurrentUser' -RegistryKeys @($snapshot))
$errors | Should -BeNullOrEmpty
$script:CapturePlanCalls | Should -Be @($false)
}
It 'uses Sysprep registry files when restoring a user-profile backup' {
$errors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @('ApplyFeature') -SelectedUndoFeatureIds @() -Target 'User:Alice' -RegistryKeys @())
$errors | Should -BeNullOrEmpty
$script:CapturePlanCalls | Should -Be @($true)
}
It 'reports unknown selected features without deriving capture plans' {
$errors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @('UnknownFeature') -SelectedUndoFeatureIds @() -Target 'CurrentUser' -RegistryKeys @())
$errors | Should -Contain "Selected feature 'UnknownFeature' was not found in the current feature catalog."
$script:CapturePlanCalls | Should -BeNullOrEmpty
}
It 'rejects registry snapshots when no feature definitions are loaded' {
$script:Features = @{}
$snapshot = [PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Example'; Values = @(); SubKeys = @() }
$errors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @('ApplyFeature') -SelectedUndoFeatureIds @() -Target 'CurrentUser' -RegistryKeys @($snapshot))
$errors | Should -Contain 'Unable to validate registry backup allowlist because feature definitions are not loaded.'
}
}
+31
View File
@@ -118,6 +118,14 @@ Describe 'Convert-RegistryKeyToSnapshot' {
$snapshot.SubKeys[0].Values[0].Data | Should -Be 'data'
$child.Closed | Should -BeTrue
}
It 'fails instead of silently omitting an unreadable child key' {
$root = New-FakeRegistryKey -Children @{ Locked = $null }
{
Convert-RegistryKeyToSnapshot -RegistryKey $root -FullPath $root.Name -CaptureAllValues:$true -IncludeSubKeys:$true
} | Should -Throw '*Unable to read registry subkey*The backup was not created*'
}
}
Describe 'Get-RegistryKeySnapshot' {
@@ -203,3 +211,26 @@ Describe 'Restore-RegistryKeySnapshot - mutation flow' {
$key.Closed | Should -BeTrue
}
}
Describe 'Test-RegistryKeySnapshotCanBeRestored' {
It 'rejects invalid value data before the live registry is touched' {
$snapshot = [PSCustomObject]@{
Path = 'HKEY_CURRENT_USER\Software\Example'; Exists = $true
Values = @([PSCustomObject]@{ Name = 'TooLarge'; Exists = $true; Kind = 'DWord'; Data = 4294967296 })
SubKeys = @()
}
{ Test-RegistryKeySnapshotCanBeRestored -Snapshot $snapshot } |
Should -Throw '*Value was either too large or too small*'
}
It 'rejects a descendant that is not directly below its declared parent' {
$snapshot = [PSCustomObject]@{
Path = 'HKEY_CURRENT_USER\Software\Example'; Exists = $true; Values = @()
SubKeys = @([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Example\Nested\Child'; Exists = $true; Values = @(); SubKeys = @() })
}
{ Test-RegistryKeySnapshotCanBeRestored -Snapshot $snapshot } |
Should -Throw "*is not directly below parent*"
}
}
+108 -15
View File
@@ -2,7 +2,6 @@ BeforeAll {
function Get-UserDirectory { param($userName, $fileName, $exitIfPathNotFound) }
. (Join-Path $PSScriptRoot '..\Scripts\Features\Replace-StartMenu.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Features\Telemetry-ScheduledTasks.ps1')
}
Describe 'Get-StartMenuUserNameFromPath' {
@@ -62,6 +61,38 @@ Describe 'Get-StartMenuUserNameFromPath' {
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'current'
}
It 'preserves a recoverable copy of the current layout when restoring fails' {
$script:Params = @{}
$startMenuFile = Join-Path $TestDrive 'start2.bin'
$backupFile = Join-Path $TestDrive 'Win11Debloat-StartBackup-20260101_120000.bak'
Set-Content -LiteralPath $startMenuFile -Value 'current'
Set-Content -LiteralPath $backupFile -Value 'backup'
Mock Copy-Item { throw 'disk full' }
$result = Restore-StartMenuFromBackup -StartMenuBinFile $startMenuFile -BackupFilePath $backupFile
$result.Result | Should -BeFalse
$result.Message | Should -Match 'disk full'
$restoreCopies = @(Get-ChildItem -LiteralPath $TestDrive -Filter 'Win11Debloat-StartRestore-*.bak')
$restoreCopies.Count | Should -Be 1
Get-Content -LiteralPath $restoreCopies[0].FullName -Raw | Should -Match 'current'
}
It 'leaves the original layout in place when it cannot be moved for restore' {
$script:Params = @{}
$startMenuFile = Join-Path $TestDrive 'start2.bin'
$backupFile = Join-Path $TestDrive 'Win11Debloat-StartBackup-20260101_120000.bak'
Set-Content -LiteralPath $startMenuFile -Value 'current'
Set-Content -LiteralPath $backupFile -Value 'backup'
Mock Move-Item { throw 'locked' }
$result = Restore-StartMenuFromBackup -StartMenuBinFile $startMenuFile -BackupFilePath $backupFile
$result.Result | Should -BeFalse
$result.Message | Should -Match 'locked'
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'current'
}
It 'delegates current-user restore to the common backup operation' {
$script:Params = @{}
Mock Restore-StartMenuFromBackup { [PSCustomObject]@{ Result = $true } }
@@ -88,21 +119,83 @@ Describe 'Get-StartMenuUserNameFromPath' {
Test-Path -LiteralPath $defaultBin | Should -BeFalse
}
}
Describe 'Replace-StartMenu' {
BeforeEach {
$script:Params = @{}
Mock Write-Host {}
}
Describe 'Get-TelemetryScheduledTasks' {
It 'returns the expected telemetry task catalog' {
$tasks = @(Get-TelemetryScheduledTasks)
It 'backs up and replaces an existing start-menu file' {
$startMenuFile = Join-Path $TestDrive 'start2.bin'
$templateFile = Join-Path $TestDrive 'template.bin'
Set-Content -LiteralPath $startMenuFile -Value 'current layout'
Set-Content -LiteralPath $templateFile -Value 'replacement layout'
$tasks.Count | Should -Be 8
@($tasks | ForEach-Object { "$($_.Path)|$($_.Name)" }) | Should -Be @(
'\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser'
'\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser Exp'
'\Microsoft\Windows\Application Experience\|ProgramDataUpdater'
'\Microsoft\Windows\Application Experience\|StartupAppTask'
'\Microsoft\Windows\Customer Experience Improvement Program\|Consolidator'
'\Microsoft\Windows\Customer Experience Improvement Program\|UsbCeip'
'\Microsoft\Windows\DiskDiagnostic\|Microsoft-Windows-DiskDiagnosticDataCollector'
'\Microsoft\Windows\Autochk\|Proxy'
)
Replace-StartMenu -startMenuBinFile $startMenuFile -startMenuTemplate $templateFile
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'replacement layout'
@(Get-ChildItem -LiteralPath $TestDrive -Filter 'Win11Debloat-StartBackup-*.bak').Count | Should -Be 1
}
It 'creates and replaces a missing start-menu file without a backup' {
$testDirectory = Join-Path $TestDrive 'missing-layout'
New-Item -ItemType Directory -Path $testDirectory | Out-Null
$startMenuFile = Join-Path $testDirectory 'start2.bin'
$templateFile = Join-Path $testDirectory 'template.bin'
$backupCountBefore = @(Get-ChildItem -LiteralPath $testDirectory -Filter 'Win11Debloat-StartBackup-*.bak').Count
Set-Content -LiteralPath $templateFile -Value 'replacement layout'
Replace-StartMenu -startMenuBinFile $startMenuFile -startMenuTemplate $templateFile
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'replacement layout'
@(Get-ChildItem -LiteralPath $testDirectory -Filter 'Win11Debloat-StartBackup-*.bak').Count | Should -Be $backupCountBefore
}
It 'does not change a start-menu file in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
$testDirectory = Join-Path $TestDrive 'whatif-layout'
New-Item -ItemType Directory -Path $testDirectory | Out-Null
$startMenuFile = Join-Path $testDirectory 'start2.bin'
$templateFile = Join-Path $testDirectory 'template.bin'
$backupCountBefore = @(Get-ChildItem -LiteralPath $testDirectory -Filter 'Win11Debloat-StartBackup-*.bak').Count
Set-Content -LiteralPath $startMenuFile -Value 'current layout'
Set-Content -LiteralPath $templateFile -Value 'replacement layout'
Replace-StartMenu -startMenuBinFile $startMenuFile -startMenuTemplate $templateFile
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'current layout'
@(Get-ChildItem -LiteralPath $testDirectory -Filter 'Win11Debloat-StartBackup-*.bak').Count | Should -Be $backupCountBefore
}
}
Describe 'Replace-StartMenuForAllUsers guard paths' {
BeforeEach {
$script:Params = @{}
$script:AssetsPath = $TestDrive
Mock Get-UserDirectory { Join-Path $TestDrive 'Users' }
Mock Get-ChildItem { @() }
Mock Replace-StartMenu {}
Mock New-Item {}
Mock Write-Host {}
}
It 'does not touch profiles when the template is missing' {
Replace-StartMenuForAllUsers -startMenuTemplate (Join-Path $TestDrive 'missing.bin')
Should -Invoke Get-UserDirectory -Times 0 -Exactly
Should -Invoke Replace-StartMenu -Times 0 -Exactly
}
It 'does not create or replace the Default profile in WhatIf mode' {
$template = Join-Path $TestDrive 'template.bin'
Set-Content -LiteralPath $template -Value 'template'
$script:Params = @{ WhatIf = $true }
Mock Test-Path { param($Path) $Path -eq $template }
Replace-StartMenuForAllUsers -startMenuTemplate $template
Should -Invoke New-Item -Times 0 -Exactly
Should -Invoke Replace-StartMenu -Times 0 -Exactly
}
}
+40
View File
@@ -11,6 +11,46 @@ BeforeAll {
function Get-RebootFeatureLabels { @() }
}
Describe 'Resolve-UserProfilePath helper fallbacks' {
BeforeEach {
$script:ResolvedUserSidCache = @{}
$script:MachineDomainJoinStateKnown = $null
$script:MachineIsDomainJoined = $false
$script:MachineNetBiosDomain = ''
}
It 'uses strict normalized equality on workgroup machines' {
Mock Test-MachineIsDomainJoined { $false }
(Test-UserNameMatch -UserNameA ' Alice ' -UserNameB 'alice') | Should -BeTrue
(Test-UserNameMatch -UserNameA 'alice' -UserNameB 'alice.CONTOSO') | Should -BeFalse
}
It 'matches domain-qualified names to domain-suffixed profile folders' {
Mock Test-MachineIsDomainJoined { $true }
Mock Get-ProfileFolderDomainSuffix { 'CONTOSO' }
Test-UserNameMatchesProfileLeaf -UserName 'CONTOSO\alice' -ProfileLeaf 'alice.CONTOSO' | Should -BeTrue
}
It 'returns null for a SID cache miss and does not cache blank SIDs' {
Get-CachedResolvedUserSid -Candidates @('missing') | Should -BeNullOrEmpty
Set-ResolvedUserSidCache -Candidates @('Alice') -Sid ''
$script:ResolvedUserSidCache.Count | Should -Be 0
}
It 'falls back to an exact profile directory when SID resolution is unavailable' {
Mock Resolve-UserSid { $null }
Mock Test-Path { $true }
$context = Resolve-UserProfileContext -UserName 'Alice'
$context.UserName | Should -Be 'Alice'
$context.UserSid | Should -BeNullOrEmpty
$context.ProfilePath | Should -Match 'Alice$'
}
}
Describe 'Test-UserProfileExists' {
BeforeEach {
Mock Resolve-UserProfileContext {
+219
View File
@@ -0,0 +1,219 @@
BeforeAll {
function Get-UserDirectory { param($userName, $fileName) }
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
function takeown { param([Parameter(ValueFromRemainingArguments)]$Arguments) }
function icacls { param([Parameter(ValueFromRemainingArguments)]$Arguments) }
function New-TestStoreDatabaseAcl {
param([object[]]$Access = @())
$acl = [PSCustomObject]@{
Access = $Access
AddedRules = [System.Collections.Generic.List[object]]::new()
RemovedRules = [System.Collections.Generic.List[object]]::new()
}
$acl | Add-Member -MemberType ScriptMethod -Name SetAccessRule -Value {
param($Rule)
$this.AddedRules.Add($Rule)
}
$acl | Add-Member -MemberType ScriptMethod -Name RemoveAccessRuleSpecific -Value {
param($Rule)
$this.RemovedRules.Add($Rule)
return $true
}
return $acl
}
. (Join-Path $PSScriptRoot '..\Scripts\Features\Set-StoreSearchSuggestions.ps1')
}
Describe 'Store-search suggestion all-user operations' {
BeforeEach {
$script:Params = @{}
Mock Get-UserDirectory { 'C:\Users\*\AppData\Local\Packages' }
Mock Get-ChildItem {
@(
[PSCustomObject]@{ FullName = 'C:\Users\Alice\AppData\Local\Packages' }
[PSCustomObject]@{ FullName = 'C:\Users\Bob\AppData\Local\Packages' }
)
}
Mock Get-StoreAppsDatabasePathForUser { 'C:\Users\Default\AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db' }
Mock Set-StoreSearchSuggestionsDisabled {}
Mock Set-StoreSearchSuggestionsEnabled {}
Mock Write-Warning {}
}
It 'disables suggestions for every discovered and Default profile' {
Set-StoreSearchSuggestionsDisabledForAllUsers
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 3 -Exactly
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 1 -Exactly -ParameterFilter { $StoreAppsDatabase -match 'Users\\Default\\' }
}
It 'enables suggestions for every discovered and Default profile' {
Set-StoreSearchSuggestionsEnabledForAllUsers
Should -Invoke Set-StoreSearchSuggestionsEnabled -Times 3 -Exactly
Should -Invoke Set-StoreSearchSuggestionsEnabled -Times 1 -Exactly -ParameterFilter { $StoreAppsDatabase -match 'Users\\Default\\' }
}
It 'does not add a Default profile disable operation when its Store database path cannot be resolved' {
Mock Get-StoreAppsDatabasePathForUser { $null }
Set-StoreSearchSuggestionsDisabledForAllUsers
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 2 -Exactly
}
It 'does not add a Default profile enable operation when its Store database path cannot be resolved' {
Mock Get-StoreAppsDatabasePathForUser { $null }
Set-StoreSearchSuggestionsEnabledForAllUsers
Should -Invoke Set-StoreSearchSuggestionsEnabled -Times 2 -Exactly
}
}
Describe 'Set-StoreSearchSuggestionsDisabled' {
BeforeEach {
$script:Params = @{ WhatIf = $true }
Mock Test-Path { throw 'WhatIf should return before filesystem access.' }
Mock Get-Acl { throw 'WhatIf should return before ACL access.' }
Mock Set-Acl { throw 'WhatIf should return before ACL access.' }
Mock Write-Host {}
}
It 'does not touch the filesystem in WhatIf mode' {
Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke Test-Path -Times 0 -Exactly
Should -Invoke Get-Acl -Times 0 -Exactly
Should -Invoke Set-Acl -Times 0 -Exactly
}
It 'creates a missing database and parent directory before applying the deny rule' {
$script:Params = @{}
$acl = New-TestStoreDatabaseAcl
Mock Test-Path { $false }
Mock New-Item {}
Mock Get-Acl { $acl }
Mock Set-Acl {}
Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke New-Item -Times 2 -Exactly
Should -Invoke New-Item -Times 1 -Exactly -ParameterFilter { $ItemType -eq 'Directory' }
Should -Invoke New-Item -Times 1 -Exactly -ParameterFilter { $ItemType -eq 'File' }
Should -Invoke New-Item -Times 1 -Exactly -ParameterFilter { $Path -eq 'C:\Users\Alice\AppData\Local\Packages' -and $ItemType -eq 'Directory' -and $Force }
Should -Invoke New-Item -Times 1 -Exactly -ParameterFilter { $Path -eq 'C:\Users\Alice\AppData\Local\Packages\store.db' -and $ItemType -eq 'File' -and $Force }
Should -Invoke Set-Acl -Times 1 -Exactly -ParameterFilter { $Path -eq 'C:\Users\Alice\AppData\Local\Packages\store.db' -and $AclObject -eq $acl }
$acl.AddedRules | Should -HaveCount 1
}
It 'updates the ACL without creating anything when the database already exists' {
$script:Params = @{}
$acl = New-TestStoreDatabaseAcl
Mock Test-Path { $true }
Mock New-Item { throw 'Existing database must not be recreated.' }
Mock Get-Acl { $acl }
Mock Set-Acl {}
Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke New-Item -Times 0 -Exactly
Should -Invoke Get-Acl -Times 1 -Exactly
Should -Invoke Set-Acl -Times 1 -Exactly
$acl.AddedRules | Should -HaveCount 1
}
It 'surfaces ACL failures instead of reporting the database as disabled' {
$script:Params = @{}
Mock Test-Path { $true }
Mock Get-Acl { throw 'access denied' }
Mock Set-Acl { throw 'ACL must not be written after a read failure.' }
{
Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
} | Should -Throw '*access denied*'
Should -Invoke Set-Acl -Times 0 -Exactly
}
}
Describe 'Set-StoreSearchSuggestionsEnabled' {
BeforeEach {
$script:Params = @{}
Mock Test-Path { $false }
Mock Get-Acl { throw 'A missing database should return before ACL access.' }
Mock Remove-Item { throw 'A missing database should not be removed.' }
Mock Write-Host {}
}
It 'does nothing when the Store database does not exist' {
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke Get-Acl -Times 0 -Exactly
Should -Invoke Remove-Item -Times 0 -Exactly
}
It 'does not touch an existing database in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Mock Test-Path { throw 'WhatIf should return before filesystem access.' }
Mock takeown { throw 'WhatIf should not take ownership.' }
Mock icacls { throw 'WhatIf should not change ACLs.' }
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke Test-Path -Times 0 -Exactly
Should -Invoke takeown -Times 0 -Exactly
Should -Invoke icacls -Times 0 -Exactly
}
It 'normalizes the ACL and removes an existing database' {
$acl = New-TestStoreDatabaseAcl
Mock Test-Path { $true }
Mock takeown {}
Mock icacls {}
Mock Get-Acl { $acl }
Mock Set-Acl {}
Mock Remove-Item {}
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke takeown -Times 1 -Exactly
Should -Invoke icacls -Times 1 -Exactly
Should -Invoke Get-Acl -Times 1 -Exactly
Should -Invoke Set-Acl -Times 1 -Exactly
Should -Invoke Remove-Item -Times 1 -Exactly -ParameterFilter { $Path -eq 'C:\Users\Alice\AppData\Local\Packages\store.db' -and $Force -and $ErrorAction -eq 'Stop' }
}
It 'continues removing an existing database when ACL normalization fails' {
Mock Test-Path { $true }
Mock takeown {}
Mock icacls {}
Mock Get-Acl { throw 'access denied' }
Mock Set-Acl { throw 'Set-Acl must not run after Get-Acl fails.' }
Mock Remove-Item {}
Mock Write-Warning {}
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke Write-Warning -Times 1 -Exactly
Should -Invoke Set-Acl -Times 0 -Exactly
Should -Invoke Remove-Item -Times 1 -Exactly
}
It 'throws a contextual error when the database cannot be removed' {
$acl = New-TestStoreDatabaseAcl
Mock Test-Path { $true }
Mock takeown {}
Mock icacls {}
Mock Get-Acl { $acl }
Mock Set-Acl {}
Mock Remove-Item { throw 'database is locked' }
{
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
} | Should -Throw '*Failed to remove*database is locked*'
}
}
+182
View File
@@ -0,0 +1,182 @@
BeforeAll {
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
. (Join-Path $PSScriptRoot '..\Scripts\Features\Telemetry-ScheduledTasks.ps1')
}
Describe 'Get-TelemetryScheduledTasks' {
It 'returns the expected telemetry task catalog' {
$tasks = @(Get-TelemetryScheduledTasks)
$tasks.Count | Should -Be 8
@($tasks | ForEach-Object { "$($_.Path)|$($_.Name)" }) | Should -Be @(
'\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser'
'\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser Exp'
'\Microsoft\Windows\Application Experience\|ProgramDataUpdater'
'\Microsoft\Windows\Application Experience\|StartupAppTask'
'\Microsoft\Windows\Customer Experience Improvement Program\|Consolidator'
'\Microsoft\Windows\Customer Experience Improvement Program\|UsbCeip'
'\Microsoft\Windows\DiskDiagnostic\|Microsoft-Windows-DiskDiagnosticDataCollector'
'\Microsoft\Windows\Autochk\|Proxy'
)
}
}
Describe 'Disable-TelemetryScheduledTasks' {
BeforeEach {
$script:Params = @{}
$script:CancelRequested = $false
Mock Get-TelemetryScheduledTasks {
@(
@{ Path = '\Microsoft\Windows\Test\'; Name = 'First' }
@{ Path = '\Microsoft\Windows\Test\'; Name = 'Second' }
)
}
Mock Invoke-NonBlocking { @{ Success = $true; Status = 'Disabled' } }
Mock Write-Host {}
}
It 'dispatches every task to the non-blocking scheduler' {
Disable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 2 -Exactly
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter {
$ArgumentList[0] -eq '\Microsoft\Windows\Test\' -and $ArgumentList[1] -eq 'First'
}
}
It 'does not schedule work after cancellation' {
$script:CancelRequested = $true
Disable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'does not schedule task changes in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Disable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'disables an enabled task inside the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:taskBlock = $ScriptBlock
$script:taskArguments = $ArgumentList
}
Mock Import-Module {}
Mock Get-ScheduledTask { [PSCustomObject]@{ State = 'Ready' } }
Mock Disable-ScheduledTask {}
Disable-TelemetryScheduledTasks
$result = & $script:taskBlock @script:taskArguments
$result.Status | Should -Be 'Disabled'
Should -Invoke Disable-ScheduledTask -Times 1 -Exactly -ParameterFilter { $TaskPath -eq '\Microsoft\Windows\Test\' -and $TaskName -eq 'Second' }
}
It 'reports an error returned by the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:taskBlock = $ScriptBlock
$script:taskArguments = $ArgumentList
}
Mock Import-Module {}
Mock Get-ScheduledTask { [PSCustomObject]@{ State = 'Ready' } }
Mock Disable-ScheduledTask { throw 'access denied' }
Disable-TelemetryScheduledTasks
$result = & $script:taskBlock @script:taskArguments
$result.Status | Should -Be 'Error'
$result.Error | Should -Match 'access denied'
}
It 'reports <Status> task results' -ForEach @(
@{ Status = 'Disabled'; Expected = 'Disabled Scheduled Task' }
@{ Status = 'AlreadyDisabled'; Expected = 'already disabled' }
@{ Status = 'NotFound'; Expected = 'not found' }
@{ Status = 'Error'; Expected = 'Failed to disable Scheduled Task' }
) {
Mock Get-TelemetryScheduledTasks { @(@{ Path = '\Microsoft\Windows\Test\'; Name = 'Telemetry' }) }
Mock Invoke-NonBlocking { @{ Success = $Status -ne 'Error'; Status = $Status; Error = 'denied' } }
Disable-TelemetryScheduledTasks
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like "*$Expected*" }
}
}
Describe 'Enable-TelemetryScheduledTasks' {
BeforeEach {
$script:Params = @{}
$script:CancelRequested = $false
Mock Get-TelemetryScheduledTasks {
@(
@{ Path = '\Microsoft\Windows\Test\'; Name = 'First' }
@{ Path = '\Microsoft\Windows\Test\'; Name = 'Second' }
)
}
Mock Invoke-NonBlocking { @{ Success = $true; Status = 'Enabled' } }
Mock Write-Host {}
}
It 'dispatches every task to the non-blocking scheduler' {
Enable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 2 -Exactly
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter {
$ArgumentList[0] -eq '\Microsoft\Windows\Test\' -and $ArgumentList[1] -eq 'First'
}
}
It 'does not schedule work after cancellation' {
$script:CancelRequested = $true
Enable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'does not schedule task changes in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Enable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'enables a disabled task inside the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:taskBlock = $ScriptBlock
$script:taskArguments = $ArgumentList
}
Mock Import-Module {}
Mock Get-ScheduledTask { [PSCustomObject]@{ State = 'Disabled' } }
Mock Enable-ScheduledTask {}
Enable-TelemetryScheduledTasks
$result = & $script:taskBlock @script:taskArguments
$result.Status | Should -Be 'Enabled'
Should -Invoke Enable-ScheduledTask -Times 1 -Exactly -ParameterFilter { $TaskPath -eq '\Microsoft\Windows\Test\' -and $TaskName -eq 'Second' }
}
It 'reports <Status> task results' -ForEach @(
@{ Status = 'Enabled'; Expected = 'Enabled Scheduled Task' }
@{ Status = 'AlreadyEnabled'; Expected = 'already enabled' }
@{ Status = 'NotFound'; Expected = 'not found' }
@{ Status = 'Error'; Expected = 'Failed to enable Scheduled Task' }
) {
Mock Get-TelemetryScheduledTasks { @(@{ Path = '\Microsoft\Windows\Test\'; Name = 'Telemetry' }) }
Mock Invoke-NonBlocking { @{ Success = $Status -ne 'Error'; Status = $Status; Error = 'denied' } }
Enable-TelemetryScheduledTasks
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like "*$Expected*" }
}
}
@@ -0,0 +1,6 @@
{
"Version": "1.0",
"Deployment": [
{ "Name": "SkipRegistryBackup", "Value": true }
]
}
@@ -4,6 +4,7 @@
{ "Name": "UserSelectionIndex", "Value": 0 },
{ "Name": "AppRemovalScopeIndex", "Value": 0 },
{ "Name": "CreateRestorePoint", "Value": true },
{ "Name": "SkipRegistryBackup", "Value": false },
{ "Name": "RestartExplorer", "Value": false }
],
"Tweaks": [
+93
View File
@@ -0,0 +1,93 @@
Describe 'Test suite safety convention' {
It 'does not directly execute high-impact Windows mutation commands' {
$protectedCommands = @(
'Add-AppxPackage'
'Add-AppxProvisionedPackage'
'Add-WindowsCapability'
'Checkpoint-Computer'
'Clear-ItemProperty'
'Disable-ComputerRestore'
'Disable-ScheduledTask'
'Disable-WindowsOptionalFeature'
'Enable-ComputerRestore'
'Enable-ScheduledTask'
'Enable-WindowsOptionalFeature'
'Install-Package'
'Invoke-Expression'
'iex'
'New-ItemProperty'
'New-PSDrive'
'Register-ScheduledTask'
'Remove-AppxPackage'
'Remove-AppxProvisionedPackage'
'Remove-ItemProperty'
'Remove-WindowsCapability'
'Restart-Service'
'Set-Acl'
'Set-ExecutionPolicy'
'Set-ItemProperty'
'Set-MpPreference'
'Set-Service'
'Set-WinUserLanguageList'
'Start-Service'
'Start-Process'
'Stop-Service'
'Stop-Process'
'Uninstall-Package'
'Unregister-ScheduledTask'
'bcdedit'
'bcdedit.exe'
'dism'
'dism.exe'
'fsutil'
'fsutil.exe'
'takeown'
'icacls'
'reg'
'reg.exe'
'schtasks'
'schtasks.exe'
'sc.exe'
'wevtutil'
'wevtutil.exe'
)
$violations = foreach ($testFile in Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.Tests.ps1' -File) {
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($testFile.FullName, [ref]$tokens, [ref]$parseErrors)
$parseErrors | Should -BeNullOrEmpty
foreach ($command in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)) {
$commandName = $command.GetCommandName()
if ($commandName -and $protectedCommands -contains $commandName) {
"$($testFile.Name): $commandName"
}
}
}
$violations | Should -BeNullOrEmpty
}
It 'does not use literal system paths with direct filesystem mutation commands' {
$fileMutationCommands = @('Clear-Content', 'Copy-Item', 'Move-Item', 'New-Item', 'Remove-Item', 'Set-Content', 'Set-Item')
$violations = foreach ($testFile in Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.Tests.ps1' -File) {
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($testFile.FullName, [ref]$tokens, [ref]$parseErrors)
foreach ($command in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)) {
if ($fileMutationCommands -notcontains $command.GetCommandName()) { continue }
foreach ($element in $command.CommandElements) {
if ($element -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) { continue }
if ($element.Value -match '^(?:[A-Za-z]:\\|\\\\|(?:HKCU|HKLM|HKU|HKCR):)') {
"$($testFile.Name): $($command.GetCommandName()) $($element.Value)"
}
}
}
}
$violations | Should -BeNullOrEmpty
}
}
+11
View File
@@ -0,0 +1,11 @@
Describe 'Wait-ForKeyPress' {
It 'exits cleanly without prompting when Silent is enabled' {
$scriptPath = Join-Path $PSScriptRoot '..\Scripts\CLI\Wait-ForKeyPress.ps1'
$command = "function Stop-Transcript {}; `$global:Silent = `$true; . '$scriptPath'; Wait-ForKeyPress"
$encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
& powershell.exe -NoProfile -EncodedCommand $encodedCommand
$LASTEXITCODE | Should -Be 0
}
}
+156
View File
@@ -0,0 +1,156 @@
BeforeAll {
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
function Enable-WindowsOptionalFeature {
param([switch]$Online, $FeatureName, [switch]$All, [switch]$NoRestart)
[void]$global:OptionalFeatureCalls.Add([PSCustomObject]@{ Action = 'Enable'; Online = $Online; FeatureName = $FeatureName; All = $All; NoRestart = $NoRestart })
}
function Disable-WindowsOptionalFeature {
param([switch]$Online, $FeatureName, [switch]$NoRestart)
[void]$global:OptionalFeatureCalls.Add([PSCustomObject]@{ Action = 'Disable'; Online = $Online; FeatureName = $FeatureName; All = $false; NoRestart = $NoRestart })
}
function Get-WindowsOptionalFeature {
param([switch]$Online, $FeatureName, $ErrorAction)
[void]$global:OptionalFeatureQueryCalls.Add([PSCustomObject]@{ Online = $Online; FeatureName = $FeatureName; ErrorAction = $ErrorAction })
if ($global:OptionalFeatureQueryThrows) {
throw 'feature service unavailable'
}
return [PSCustomObject]@{ State = $global:OptionalFeatureQueryState }
}
. (Join-Path $PSScriptRoot '..\Scripts\Features\Windows-OptionalFeatures.ps1')
}
Describe 'Enable-WindowsFeature' {
BeforeEach {
$script:Params = @{}
Mock Invoke-NonBlocking { @() }
Mock Write-Host {}
}
It 'schedules the requested feature with the non-blocking runner' {
Enable-WindowsFeature -FeatureName 'Feature.One'
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $ArgumentList -eq 'Feature.One' }
}
It 'calls Enable-WindowsOptionalFeature with the expected arguments' {
$global:OptionalFeatureCalls = [System.Collections.Generic.List[object]]::new()
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:optionalFeatureBlock = $ScriptBlock
$script:optionalFeatureArguments = $ArgumentList
}
Enable-WindowsFeature -FeatureName 'Feature.One'
& $script:optionalFeatureBlock $script:optionalFeatureArguments
$global:OptionalFeatureCalls | Should -HaveCount 1
$global:OptionalFeatureCalls[0].Action | Should -Be 'Enable'
$global:OptionalFeatureCalls[0].Online | Should -BeTrue
$global:OptionalFeatureCalls[0].FeatureName | Should -Be 'Feature.One'
$global:OptionalFeatureCalls[0].All | Should -BeTrue
$global:OptionalFeatureCalls[0].NoRestart | Should -BeTrue
}
It 'does not schedule changes in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Enable-WindowsFeature -FeatureName 'Feature.One'
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'surfaces an optional-feature enable failure from the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:optionalFeatureBlock = $ScriptBlock
$script:optionalFeatureArguments = $ArgumentList
}
Mock Enable-WindowsOptionalFeature { throw 'feature servicing failed' }
Enable-WindowsFeature -FeatureName 'Feature.One'
{ & $script:optionalFeatureBlock $script:optionalFeatureArguments } | Should -Throw 'feature servicing failed'
}
}
Describe 'Disable-WindowsFeature' {
BeforeEach {
$script:Params = @{}
Mock Invoke-NonBlocking { @() }
Mock Write-Host {}
}
It 'schedules the requested feature with the non-blocking runner' {
Disable-WindowsFeature -FeatureName 'Feature.One'
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $ArgumentList -eq 'Feature.One' }
}
It 'calls Disable-WindowsOptionalFeature with the expected arguments' {
$global:OptionalFeatureCalls = [System.Collections.Generic.List[object]]::new()
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:optionalFeatureBlock = $ScriptBlock
$script:optionalFeatureArguments = $ArgumentList
}
Disable-WindowsFeature -FeatureName 'Feature.One'
& $script:optionalFeatureBlock $script:optionalFeatureArguments
$global:OptionalFeatureCalls | Should -HaveCount 1
$global:OptionalFeatureCalls[0].Action | Should -Be 'Disable'
$global:OptionalFeatureCalls[0].Online | Should -BeTrue
$global:OptionalFeatureCalls[0].FeatureName | Should -Be 'Feature.One'
$global:OptionalFeatureCalls[0].All | Should -BeFalse
$global:OptionalFeatureCalls[0].NoRestart | Should -BeTrue
}
It 'does not schedule changes in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Disable-WindowsFeature -FeatureName 'Feature.One'
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'surfaces an optional-feature disable failure from the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:optionalFeatureBlock = $ScriptBlock
$script:optionalFeatureArguments = $ArgumentList
}
Mock Disable-WindowsOptionalFeature { throw 'feature servicing failed' }
Disable-WindowsFeature -FeatureName 'Feature.One'
{ & $script:optionalFeatureBlock $script:optionalFeatureArguments } | Should -Throw 'feature servicing failed'
}
}
Describe 'Test-WindowsOptionalFeatureEnabled' {
BeforeEach {
$global:OptionalFeatureQueryCalls = [System.Collections.Generic.List[object]]::new()
$global:OptionalFeatureQueryThrows = $false
$global:OptionalFeatureQueryState = 'Disabled'
}
It 'returns true for an enabled optional feature' {
$global:OptionalFeatureQueryState = 'Enabled'
Test-WindowsOptionalFeatureEnabled -FeatureName 'Feature.One' | Should -BeTrue
$global:OptionalFeatureQueryCalls | Should -HaveCount 1
$global:OptionalFeatureQueryCalls[0].Online | Should -BeTrue
$global:OptionalFeatureQueryCalls[0].FeatureName | Should -Be 'Feature.One'
$global:OptionalFeatureQueryCalls[0].ErrorAction | Should -Be 'Stop'
}
It 'returns false for a non-enabled optional feature' {
Test-WindowsOptionalFeatureEnabled -FeatureName 'Feature.One' | Should -BeFalse
}
It 'returns false when the optional-feature query fails' {
$global:OptionalFeatureQueryThrows = $true
Test-WindowsOptionalFeatureEnabled -FeatureName 'Feature.One' | Should -BeFalse
}
}
+62
View File
@@ -0,0 +1,62 @@
Describe 'XAML UI contracts' {
BeforeAll {
$script:SchemaPath = Join-Path $PSScriptRoot '..\Schemas'
$script:GuiPath = Join-Path $PSScriptRoot '..\Scripts\GUI'
$script:XamlFiles = @(Get-ChildItem -LiteralPath $script:SchemaPath -Filter '*.xaml' -File)
}
It 'keeps every schema well-formed XML' {
foreach ($xamlFile in $script:XamlFiles) {
{ [xml](Get-Content -LiteralPath $xamlFile.FullName -Raw) } |
Should -Not -Throw "XAML schema '$($xamlFile.Name)' must remain well-formed."
}
}
It 'keeps every literal FindName reference backed by an XAML control' {
$xamlNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($xamlFile in $script:XamlFiles) {
$content = Get-Content -LiteralPath $xamlFile.FullName -Raw
foreach ($match in [regex]::Matches($content, '(?:x:Name|\bName)\s*=\s*"([^"]+)"')) {
[void]$xamlNames.Add($match.Groups[1].Value)
}
}
$references = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($guiFile in Get-ChildItem -LiteralPath $script:GuiPath -Filter '*.ps1' -File -Recurse) {
$content = Get-Content -LiteralPath $guiFile.FullName -Raw
foreach ($match in [regex]::Matches($content, "\.FindName\(\s*'([^']+)'\s*\)")) {
[void]$references.Add($match.Groups[1].Value)
}
}
$missing = @($references | Where-Object { -not $xamlNames.Contains($_) } | Sort-Object)
$missing | Should -BeNullOrEmpty
}
It 'keeps the main window navigation and deployment controls available' {
$mainWindow = Get-Content -LiteralPath (Join-Path $script:SchemaPath 'MainWindow.xaml') -Raw
$requiredNames = @(
'MainTabControl'
'HomeTab'
'DeploymentSettingsTab'
'DeploymentApplyBtn'
'UserSelectionCombo'
'AppSelectionPanel'
'AppSelectionStatus'
)
foreach ($name in $requiredNames) {
$mainWindow | Should -Match ('(?:x:Name|\bName)\s*=\s*"{0}"' -f [regex]::Escape($name))
}
}
It 'keeps destructive modal actions accessible by automation name' {
$applyWindow = Get-Content -LiteralPath (Join-Path $script:SchemaPath 'ApplyChangesWindow.xaml') -Raw
$appWindow = Get-Content -LiteralPath (Join-Path $script:SchemaPath 'AppSelectionWindow.xaml') -Raw
$applyWindow | Should -Match 'AutomationProperties.Name="Cancel"'
$applyWindow | Should -Match 'AutomationProperties.Name="Close"'
$appWindow | Should -Match 'AutomationProperties.Name="Confirm"'
$appWindow | Should -Match 'AutomationProperties.Name="Cancel"'
}
}