diff --git a/Config/DefaultSettings.json b/Config/DefaultSettings.json index 815b492..e3cf0b4 100644 --- a/Config/DefaultSettings.json +++ b/Config/DefaultSettings.json @@ -70,4 +70,4 @@ "Value": true } ] -} \ No newline at end of file +} diff --git a/Schemas/MainWindow.xaml b/Schemas/MainWindow.xaml index 68846cf..0a7aba8 100644 --- a/Schemas/MainWindow.xaml +++ b/Schemas/MainWindow.xaml @@ -933,12 +933,14 @@ - + + + + - diff --git a/Scripts/Features/Backup-RegistrySnapshotCapture.ps1 b/Scripts/Features/Backup-RegistrySnapshotCapture.ps1 index 1dcc405..5fe57d6 100644 --- a/Scripts/Features/Backup-RegistrySnapshotCapture.ps1 +++ b/Scripts/Features/Backup-RegistrySnapshotCapture.ps1 @@ -201,7 +201,8 @@ function Get-RegistryKeySnapshot { .DESCRIPTION Captures all values or selected value names, records missing selected values, - and recursively captures subkeys when requested. + and recursively captures subkeys when requested. Throws if a requested subkey + cannot be read. #> function Convert-RegistryKeyToSnapshot { param( @@ -241,7 +242,9 @@ function Convert-RegistryKeyToSnapshot { if ($IncludeSubKeys) { foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) { $childKey = $RegistryKey.OpenSubKey($subKeyName, $false) - if ($null -eq $childKey) { continue } + if ($null -eq $childKey) { + throw "Unable to read registry subkey '$($RegistryKey.Name)\$subKeyName' while creating a backup snapshot. The backup was not created." + } try { $childPath = if ([string]::IsNullOrWhiteSpace($FullPath)) { $subKeyName } else { "$FullPath\$subKeyName" } diff --git a/Scripts/Features/Invoke-Changes.ps1 b/Scripts/Features/Invoke-Changes.ps1 index b577065..85579e5 100644 --- a/Scripts/Features/Invoke-Changes.ps1 +++ b/Scripts/Features/Invoke-Changes.ps1 @@ -121,7 +121,7 @@ function Invoke-FeatureApply { 'DisableStoreSearchSuggestions' { if ($script:Params.ContainsKey("Sysprep")) { Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..." - DisableStoreSearchSuggestionsForAllUsers + Set-StoreSearchSuggestionsDisabledForAllUsers Write-Host "" return } @@ -159,7 +159,7 @@ function Invoke-FeatureUndo { 'DisableStoreSearchSuggestions' { if ($script:Params.ContainsKey('Sysprep')) { Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..." - EnableStoreSearchSuggestionsForAllUsers + Set-StoreSearchSuggestionsEnabledForAllUsers Write-Host "" return } @@ -302,8 +302,8 @@ function Invoke-UndoFeatures { .DESCRIPTION Sequenced in four phases: - 1. Registry backup - 2. System restore point + 1. Registry backup (skipped when SkipRegistryBackup is present) + 2. System restore point (skipped when CreateRestorePoint is absent) 3. Apply phase - applies all selected features via Invoke-ApplyFeatures 4. Undo phase - undoes selected features via Invoke-UndoFeatures @@ -349,14 +349,14 @@ function Invoke-AllChanges { # ---- Calculate total progress steps ---- $totalSteps = $applyIds.Count + $undoIds.Count - if ($needsBackup) { $totalSteps++ } + if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) { $totalSteps++ } if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ } $step = 0 # ================================================================ # Phase 1: Registry backup # ================================================================ - if ($needsBackup) { + if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) { if ($script:CancelRequested) { return } $step++ if ($script:ApplyProgressCallback) { diff --git a/Scripts/Features/Ensure-SystemRestorePoint.ps1 b/Scripts/Features/Invoke-SystemRestorePoint.ps1 similarity index 100% rename from Scripts/Features/Ensure-SystemRestorePoint.ps1 rename to Scripts/Features/Invoke-SystemRestorePoint.ps1 diff --git a/Scripts/Features/Restore-RegistryApplyState.ps1 b/Scripts/Features/Restore-RegistryApplyState.ps1 index 615956a..13e8f7f 100644 --- a/Scripts/Features/Restore-RegistryApplyState.ps1 +++ b/Scripts/Features/Restore-RegistryApplyState.ps1 @@ -65,17 +65,103 @@ function Restore-RegistryKeySnapshot { throw "Unsupported root-level registry path in backup: $($Snapshot.Path)" } + Test-RegistryKeySnapshotCanBeRestored -Snapshot $Snapshot + Restore-RegistryKeySnapshotAtPath -Snapshot $Snapshot -RootKey $rootKey -SubKeyPath $subKeyPath +} + +<# + .SYNOPSIS + Validates registry values and subkey paths in a snapshot before live registry state is changed. + + .PARAMETER Snapshot + The registry key snapshot to validate before it is restored. +#> +function Test-RegistryKeySnapshotCanBeRestored { + param( + [Parameter(Mandatory)] + $Snapshot + ) + + if (-not [bool]$Snapshot.Exists) { return } + + $childNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($valueSnapshot in @($Snapshot.Values)) { + if ([bool]$valueSnapshot.Exists) { + $valueKind = Convert-RegistryValueKindFromBackup -KindName $valueSnapshot.Kind + $null = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $valueSnapshot.Data + } + } + + foreach ($subKeySnapshot in @($Snapshot.SubKeys)) { + $childName = Get-DirectRegistrySnapshotChildName -ParentPath $Snapshot.Path -ChildPath $subKeySnapshot.Path + if ([string]::IsNullOrWhiteSpace($childName) -or -not $childNames.Add($childName)) { + throw "Backup contains duplicate or unsupported registry child path: $($subKeySnapshot.Path)" + } + Test-RegistryKeySnapshotCanBeRestored -Snapshot $subKeySnapshot + } +} + +<# + .SYNOPSIS + Returns a snapshot child's name only when it is directly below its parent. + + .PARAMETER ParentPath + The registry path of the expected parent snapshot. + + .PARAMETER ChildPath + The registry path of the child snapshot to validate. +#> +function Get-DirectRegistrySnapshotChildName { + param( + [Parameter(Mandatory)] + [string]$ParentPath, + [Parameter(Mandatory)] + [string]$ChildPath + ) + + $parentParts = Split-RegistryPath -path $ParentPath + $childParts = Split-RegistryPath -path $ChildPath + if (-not $parentParts -or -not $childParts -or + -not $parentParts.Hive.Equals($childParts.Hive, [System.StringComparison]::OrdinalIgnoreCase) -or + [string]::IsNullOrWhiteSpace($parentParts.SubKey) -or + [string]::IsNullOrWhiteSpace($childParts.SubKey)) { + throw "Unsupported registry child path in backup: $ChildPath" + } + + $childName = Split-Path -Path $childParts.SubKey -Leaf + $expectedSubKey = "$($parentParts.SubKey)\$childName" + if ([string]::IsNullOrWhiteSpace($childName) -or + -not $childParts.SubKey.Equals($expectedSubKey, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Registry child path '$ChildPath' is not directly below parent '$ParentPath'." + } + + return $childName +} + +<# + .SYNOPSIS + Restores a snapshot to a specific path below an already resolved registry root. + + .DESCRIPTION + Writes only values and descendants represented by the backup. Existing keys are + retained so their security descriptors and unrelated data are not destroyed. +#> +function Restore-RegistryKeySnapshotAtPath { + param( + [Parameter(Mandatory)] + $Snapshot, + [Parameter(Mandatory)] + $RootKey, + [Parameter(Mandatory)] + [string]$SubKeyPath + ) + if (-not $Snapshot.Exists) { - Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath + Remove-RegistrySubKeyTreeIfExists -RootKey $RootKey -SubKeyPath $SubKeyPath return } - $forceFullTree = @($Snapshot.SubKeys).Count -gt 0 - if ($forceFullTree) { - Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath - } - - $key = $rootKey.CreateSubKey($subKeyPath) + $key = $RootKey.CreateSubKey($SubKeyPath) if ($null -eq $key) { throw "Unable to create or open registry key '$($Snapshot.Path)'" } @@ -90,8 +176,11 @@ function Restore-RegistryKeySnapshot { } foreach ($subKeySnapshot in @($Snapshot.SubKeys)) { - Restore-RegistryKeySnapshot -Snapshot $subKeySnapshot + $childName = Get-DirectRegistrySnapshotChildName -ParentPath $Snapshot.Path -ChildPath $subKeySnapshot.Path + + Restore-RegistryKeySnapshotAtPath -Snapshot $subKeySnapshot -RootKey $RootKey -SubKeyPath "$SubKeyPath\$childName" } + } <# diff --git a/Scripts/GUI/Show-ImportExportConfigWindow.ps1 b/Scripts/GUI/Show-ImportExportConfigWindow.ps1 index b6b9aaa..36055ee 100644 --- a/Scripts/GUI/Show-ImportExportConfigWindow.ps1 +++ b/Scripts/GUI/Show-ImportExportConfigWindow.ps1 @@ -219,6 +219,11 @@ function Get-DeploymentSettings { $deploySettings += @{ Name = 'CreateRestorePoint'; Value = [bool]$restorePointCheckBox.IsChecked } } + $registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox') + if ($registryBackupCheckBox) { + $deploySettings += @{ Name = 'SkipRegistryBackup'; Value = -not [bool]$registryBackupCheckBox.IsChecked } + } + $restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox') if ($restartExplorerCheckBox) { $deploySettings += @{ Name = 'RestartExplorer'; Value = [bool]$restartExplorerCheckBox.IsChecked } @@ -272,6 +277,7 @@ function Get-DeploymentCategoryDetailString { $options = @() if ($lookup.ContainsKey('CreateRestorePoint') -and [bool]$lookup['CreateRestorePoint']) { $options += 'Restore Point' } + if (-not ($lookup.ContainsKey('SkipRegistryBackup') -and [bool]$lookup['SkipRegistryBackup'])) { $options += 'Registry Backup' } if ($lookup.ContainsKey('RestartExplorer') -and [bool]$lookup['RestartExplorer']) { $options += 'Restart Explorer' } $lines = @() @@ -378,6 +384,13 @@ function Set-ImportedDeploymentSettings { $restorePointCheckBox.IsChecked = [bool]$lookup['CreateRestorePoint'] } + $registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox') + if ($registryBackupCheckBox) { + if ($lookup.ContainsKey('SkipRegistryBackup')) { + $registryBackupCheckBox.IsChecked = -not [bool]$lookup['SkipRegistryBackup'] + } + } + $restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox') if ($lookup.ContainsKey('RestartExplorer') -and $restartExplorerCheckBox) { $restartExplorerCheckBox.IsChecked = [bool]$lookup['RestartExplorer'] diff --git a/Scripts/GUI/Show-MainWindow.ps1 b/Scripts/GUI/Show-MainWindow.ps1 index e21716a..7fc1319 100644 --- a/Scripts/GUI/Show-MainWindow.ps1 +++ b/Scripts/GUI/Show-MainWindow.ps1 @@ -702,6 +702,11 @@ function Show-MainWindow { Add-Parameter 'CreateRestorePoint' } + $registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox') + if ($registryBackupCheckBox -and -not $registryBackupCheckBox.IsChecked) { + Add-Parameter 'SkipRegistryBackup' + } + switch ($userSelectionCombo.SelectedIndex) { 0 { Write-Host "Selected user mode: current user ($(Get-UserName))" } 1 { @@ -782,6 +787,12 @@ function Show-MainWindow { $restartExplorerCheckBox.IsEnabled = $false } + $registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox') + if ($registryBackupCheckBox -and $script:Params.ContainsKey('SkipRegistryBackup')) { + $registryBackupCheckBox.IsChecked = $false + $registryBackupCheckBox.IsEnabled = $false + } + if ($script:Params.ContainsKey("Sysprep")) { $userSelectionCombo.SelectedIndex = 2 $userSelectionCombo.IsEnabled = $false diff --git a/Scripts/Get.ps1 b/Scripts/Get.ps1 index eee95c7..c13f8bc 100644 --- a/Scripts/Get.ps1 +++ b/Scripts/Get.ps1 @@ -10,6 +10,7 @@ param ( [Alias('NoRestartExplorer')] [switch]$SkipExplorerRestart, [switch]$CreateRestorePoint, + [switch]$SkipRegistryBackup, [switch]$RunDefaults, [switch]$RunDefaultsLite, [switch]$RunSavedSettings, diff --git a/Scripts/Helpers/Import-ConfigToParams.ps1 b/Scripts/Helpers/Import-ConfigToParams.ps1 index 4db4a7f..03ecd12 100644 --- a/Scripts/Helpers/Import-ConfigToParams.ps1 +++ b/Scripts/Helpers/Import-ConfigToParams.ps1 @@ -81,6 +81,11 @@ function Import-ConfigToParams { $importedItems++ } + if ($deploymentLookup.ContainsKey('SkipRegistryBackup') -and [bool]$deploymentLookup['SkipRegistryBackup']) { + Add-Parameter 'SkipRegistryBackup' + $importedItems++ + } + if ($deploymentLookup.ContainsKey('RestartExplorer') -and -not [bool]$deploymentLookup['RestartExplorer']) { Add-Parameter 'SkipExplorerRestart' $importedItems++ diff --git a/Tests/Apply-RegistryRegFile.Tests.ps1 b/Tests/Apply-RegistryRegFile.Tests.ps1 index a746b76..1719913 100644 --- a/Tests/Apply-RegistryRegFile.Tests.ps1 +++ b/Tests/Apply-RegistryRegFile.Tests.ps1 @@ -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' } + } +} diff --git a/Tests/Get-RebootFeatureLabels.Tests.ps1 b/Tests/Get-RebootFeatureLabels.Tests.ps1 index 4acf9d3..c14ab00 100644 --- a/Tests/Get-RebootFeatureLabels.Tests.ps1 +++ b/Tests/Get-RebootFeatureLabels.Tests.ps1 @@ -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 + } } diff --git a/Tests/Get-WingetInstalledApps.Tests.ps1 b/Tests/Get-WingetInstalledApps.Tests.ps1 new file mode 100644 index 0000000..2800d53 --- /dev/null +++ b/Tests/Get-WingetInstalledApps.Tests.ps1 @@ -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 } + } +} diff --git a/Tests/Import-ConfigToParams.Tests.ps1 b/Tests/Import-ConfigToParams.Tests.ps1 index c2310d2..93744af 100644 --- a/Tests/Import-ConfigToParams.Tests.ps1 +++ b/Tests/Import-ConfigToParams.Tests.ps1 @@ -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 + } } diff --git a/Tests/Invoke-Changes.Tests.ps1 b/Tests/Invoke-Changes.Tests.ps1 index 2f61018..51bc2d0 100644 --- a/Tests/Invoke-Changes.Tests.ps1 +++ b/Tests/Invoke-Changes.Tests.ps1 @@ -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' } + } } diff --git a/Tests/Invoke-NonBlocking.Tests.ps1 b/Tests/Invoke-NonBlocking.Tests.ps1 new file mode 100644 index 0000000..10b737c --- /dev/null +++ b/Tests/Invoke-NonBlocking.Tests.ps1 @@ -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' + } +} diff --git a/Tests/Invoke-SystemRestorePoint.Tests.ps1 b/Tests/Invoke-SystemRestorePoint.Tests.ps1 new file mode 100644 index 0000000..45b5826 --- /dev/null +++ b/Tests/Invoke-SystemRestorePoint.Tests.ps1 @@ -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 + } +} diff --git a/Tests/Registry-BackupFeatureValidation.Tests.ps1 b/Tests/Registry-BackupFeatureValidation.Tests.ps1 new file mode 100644 index 0000000..68b22c0 --- /dev/null +++ b/Tests/Registry-BackupFeatureValidation.Tests.ps1 @@ -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.' + } +} diff --git a/Tests/Registry-SnapshotApply.Tests.ps1 b/Tests/Registry-SnapshotApply.Tests.ps1 index f91164b..0bdba7f 100644 --- a/Tests/Registry-SnapshotApply.Tests.ps1 +++ b/Tests/Registry-SnapshotApply.Tests.ps1 @@ -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*" + } +} diff --git a/Tests/Replace-StartMenu.Tests.ps1 b/Tests/Replace-StartMenu.Tests.ps1 index 6a2bd1c..7a960f8 100644 --- a/Tests/Replace-StartMenu.Tests.ps1 +++ b/Tests/Replace-StartMenu.Tests.ps1 @@ -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 + } + +} diff --git a/Tests/Resolve-UserProfilePath.Tests.ps1 b/Tests/Resolve-UserProfilePath.Tests.ps1 index dbb07d7..2b3da08 100644 --- a/Tests/Resolve-UserProfilePath.Tests.ps1 +++ b/Tests/Resolve-UserProfilePath.Tests.ps1 @@ -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 { diff --git a/Tests/Set-StoreSearchSuggestions.Tests.ps1 b/Tests/Set-StoreSearchSuggestions.Tests.ps1 new file mode 100644 index 0000000..0da4bc1 --- /dev/null +++ b/Tests/Set-StoreSearchSuggestions.Tests.ps1 @@ -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*' + } +} diff --git a/Tests/Telemetry-ScheduledTasks.Tests.ps1 b/Tests/Telemetry-ScheduledTasks.Tests.ps1 new file mode 100644 index 0000000..9b8e70c --- /dev/null +++ b/Tests/Telemetry-ScheduledTasks.Tests.ps1 @@ -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 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 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*" } + } +} diff --git a/Tests/TestData/JsonFileLoading/ExportedConfig.SkipRegistryBackup.json b/Tests/TestData/JsonFileLoading/ExportedConfig.SkipRegistryBackup.json new file mode 100644 index 0000000..7a2489d --- /dev/null +++ b/Tests/TestData/JsonFileLoading/ExportedConfig.SkipRegistryBackup.json @@ -0,0 +1,6 @@ +{ + "Version": "1.0", + "Deployment": [ + { "Name": "SkipRegistryBackup", "Value": true } + ] +} diff --git a/Tests/TestData/JsonFileLoading/ExportedConfig.WithSettings.json b/Tests/TestData/JsonFileLoading/ExportedConfig.WithSettings.json index 195d3a8..02b7d6d 100644 --- a/Tests/TestData/JsonFileLoading/ExportedConfig.WithSettings.json +++ b/Tests/TestData/JsonFileLoading/ExportedConfig.WithSettings.json @@ -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": [ diff --git a/Tests/TestSuiteSafety.Tests.ps1 b/Tests/TestSuiteSafety.Tests.ps1 new file mode 100644 index 0000000..9abddfc --- /dev/null +++ b/Tests/TestSuiteSafety.Tests.ps1 @@ -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 + } +} diff --git a/Tests/Wait-ForKeyPress.Tests.ps1 b/Tests/Wait-ForKeyPress.Tests.ps1 new file mode 100644 index 0000000..e63396a --- /dev/null +++ b/Tests/Wait-ForKeyPress.Tests.ps1 @@ -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 + } +} diff --git a/Tests/Windows-OptionalFeatures.Tests.ps1 b/Tests/Windows-OptionalFeatures.Tests.ps1 new file mode 100644 index 0000000..f0f1186 --- /dev/null +++ b/Tests/Windows-OptionalFeatures.Tests.ps1 @@ -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 + } +} diff --git a/Tests/XamlContracts.Tests.ps1 b/Tests/XamlContracts.Tests.ps1 new file mode 100644 index 0000000..da6c545 --- /dev/null +++ b/Tests/XamlContracts.Tests.ps1 @@ -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"' + } +} diff --git a/Win11Debloat.ps1 b/Win11Debloat.ps1 index eeec087..5688b7d 100644 --- a/Win11Debloat.ps1 +++ b/Win11Debloat.ps1 @@ -8,6 +8,7 @@ param ( [Alias('NoRestartExplorer')] [switch]$SkipExplorerRestart, [switch]$CreateRestorePoint, + [switch]$SkipRegistryBackup, [switch]$RunDefaults, [switch]$RunDefaultsLite, [switch]$RunSavedSettings, @@ -186,7 +187,7 @@ $script:RestoreBackupWindowSchema = Join-Path $schemasPath 'RestoreBackupWindow. $script:LoadAppsDetailsScriptPath = Join-Path (Join-Path $scriptsPath 'FileIO') 'Import-AppDetailsFromJson.ps1' $script:TestAppInWingetListScriptPath = Join-Path (Join-Path $scriptsPath 'AppRemoval') 'Test-AppInWingetList.ps1' -$script:ControlParams = 'WhatIf', 'Confirm', 'Verbose', 'Debug', 'LogPath', 'Silent', 'Sysprep', 'User', 'SkipExplorerRestart', 'RunDefaults', 'RunDefaultsLite', 'RunSavedSettings', 'Config', 'CLI', 'AppRemovalTarget' +$script:ControlParams = 'WhatIf', 'Confirm', 'Verbose', 'Debug', 'LogPath', 'Silent', 'Sysprep', 'User', 'SkipExplorerRestart', 'SkipRegistryBackup', 'RunDefaults', 'RunDefaultsLite', 'RunSavedSettings', 'Config', 'CLI', 'AppRemovalTarget' # Script-level variables for GUI elements $script:GuiWindow = $null @@ -329,7 +330,7 @@ if (-not $script:WingetInstalled -and -not $Silent) { # Features functions . "$PSScriptRoot/Scripts/Features/Get-CurrentTweakState.ps1" . "$PSScriptRoot/Scripts/Features/Invoke-Changes.ps1" -. "$PSScriptRoot/Scripts/Features/Ensure-SystemRestorePoint.ps1" +. "$PSScriptRoot/Scripts/Features/Invoke-SystemRestorePoint.ps1" . "$PSScriptRoot/Scripts/Features/Backup-RegistryFeatureSelection.ps1" . "$PSScriptRoot/Scripts/Features/Backup-RegistrySnapshotCapture.ps1" . "$PSScriptRoot/Scripts/Features/Backup-RegistryState.ps1"