Fix uninstallation of user-scope apps in WinGet with deferred uninstall (#802)

This commit is contained in:
Jeffrey
2026-10-12 23:53:08 +02:00
committed by GitHub
parent 47cc891547
commit 89424472a2
8 changed files with 386 additions and 44 deletions
+163
View File
@@ -4,6 +4,7 @@ BeforeAll {
function Test-AppInWingetList { param($appId, $InstalledList) $false }
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList, $TimeoutSeconds) }
function Get-UserName { 'Alice' }
function Get-Translation { param($Key, $FormatArgs) if ($FormatArgs) { return "$Key $($FormatArgs -join ' ')" }; return $Key }
function Invoke-ForceRemoveEdge {}
function Show-MessageBox { 'No' }
function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject) }
@@ -20,6 +21,7 @@ Describe 'Remove-SelectedApps' {
$script:CancelRequested = $false
$script:ApplySubStepCallback = $null
$script:WingetInstalled = $true
$script:WingetDeferredRemovals = @{}
$script:AppRemovalFailures = 0
$script:AppRemovalVerificationUnavailable = $false
Mock Get-TargetUserForAppRemoval { 'AllUsers' }
@@ -54,6 +56,46 @@ Describe 'Remove-SelectedApps' {
Should -Invoke Test-AppInWingetList -Times 1 -Exactly -ParameterFilter { $appId -eq 'Winget.App' -and $InstalledList[0].Id -eq 'Other.App' }
}
It 'skips immediate inventory verification for a deferred WinGet uninstall' {
Mock Get-AppRemovalMethod { 'WinGet' }
Mock Remove-WinGetApp {
$script:WingetDeferredRemovals[$app] = 'Alice'
return $true
}
Remove-SelectedApps -appsList @('Winget.App') | Should -BeTrue
Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly
}
It 'skips an already deferred app within a list and across removal calls' {
Mock Get-AppRemovalMethod { 'WinGet' }
Mock Remove-WinGetApp {
$script:WingetDeferredRemovals[$app] = 'Alice'
return $true
}
Remove-SelectedApps -appsList @('One.App', 'One.App') | Should -BeTrue
Remove-SelectedApps -appsList @('One.App') | Should -BeTrue
Should -Invoke Remove-WinGetApp -Times 1 -Exactly -ParameterFilter { $app -eq 'One.App' }
Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly
$script:WingetDeferredRemovals['One.App'] | Should -Be 'Alice'
$script:AppRemovalFailures | Should -Be 0
}
It 'continues removing and verifying other apps while preserving deferrals' {
$script:WingetDeferredRemovals = @{ 'One.App' = 'Alice'; 'Other.App' = 'Bob' }
Mock Get-AppRemovalMethod { 'WinGet' }
Remove-SelectedApps -appsList @('One.App', 'New.App') | Should -BeTrue
Should -Invoke Remove-WinGetApp -Times 1 -Exactly -ParameterFilter { $app -eq 'New.App' }
Should -Invoke Test-AppInWingetList -Times 1 -Exactly -ParameterFilter { $appId -eq 'New.App' }
$script:WingetDeferredRemovals['One.App'] | Should -Be 'Alice'
$script:WingetDeferredRemovals['Other.App'] | Should -Be 'Bob'
}
It 'stops before the first removal when cancellation is requested' {
$script:CancelRequested = $true
Remove-SelectedApps -appsList @('One.App')
@@ -157,6 +199,7 @@ Describe 'Remove-WinGetApp' {
BeforeEach {
$script:Params = @{}
$script:WingetInstalled = $true
$script:WingetDeferredRemovals = @{}
Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $true; ExitCode = 0; Output = @() } }
Mock Set-RunOnceWingetTask { $true }
Mock Get-UserName { 'Alice' }
@@ -196,6 +239,19 @@ Describe 'Remove-WinGetApp' {
}
}
It 'defers a user-scope uninstall when WinGet returns the administrator-context error code' {
$script:Params = @{ AppRemovalTarget = 'CurrentUser' }
Mock Get-TargetUserForAppRemoval { 'CurrentUser' }
Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = -1978335107; Output = @('localized WinGet error') } }
Remove-WinGetApp -app 'One.App' | Should -BeTrue
Should -Invoke Set-RunOnceWingetTask -Times 1 -Exactly -ParameterFilter {
$appId -eq 'One.App'
}
$script:WingetDeferredRemovals['One.App'] | Should -Be 'Alice'
}
It 'reports a timed-out winget uninstall and continues' {
$script:Params = @{ User = 'Alice' }
Mock Invoke-NonBlocking { throw 'Operation timed out after 120 seconds' }
@@ -352,4 +408,111 @@ Describe 'Set-RunOnceWingetTask' {
$decodedCommand = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedCommand))
$decodedCommand | Should -Be "winget uninstall --accept-source-agreements --disable-interactivity --id 'Vendor.App&''Test'"
}
It 'schedules every resolved target but reports incomplete profile enumeration' {
Mock Get-RunOnceWingetTargetUserNames {
[PSCustomObject]@{ UserNames = @('Alice', 'Default'); Success = $false }
}
Set-RunOnceWingetTask -appId 'One.App' -Target ([PSCustomObject]@{ AllUsers = $true }) | Should -BeFalse
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Alice' }
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Default' }
}
It 'reports success when all profile targets are scheduled' {
Mock Get-RunOnceWingetTargetUserNames {
[PSCustomObject]@{ UserNames = @('Alice', 'Default'); Success = $true }
}
Set-RunOnceWingetTask -appId 'One.App' -Target ([PSCustomObject]@{ AllUsers = $true }) | Should -BeTrue
Should -Invoke Invoke-WithTargetUserHive -Times 2 -Exactly
}
It 'continues scheduling other profiles after a hive failure and reports failure' {
Mock Get-RunOnceWingetTargetUserNames {
[PSCustomObject]@{ UserNames = @('Alice', 'Default'); Success = $true }
}
Mock Invoke-WithTargetUserHive { throw 'hive unavailable' } -ParameterFilter { $TargetUserName -eq 'Alice' }
Mock Write-Error {}
Set-RunOnceWingetTask -appId 'One.App' -Target ([PSCustomObject]@{ AllUsers = $true }) | Should -BeFalse
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Default' }
}
}
Describe 'AllUsers deferred WinGet profile enumeration' {
BeforeEach {
$script:Params = @{}
$script:WingetInstalled = $true
$script:WingetDeferredRemovals = @{}
$script:AppRemovalFailures = 0
$script:CancelRequested = $false
$script:ApplySubStepCallback = $null
Mock Get-CimInstance {
@(
[PSCustomObject]@{ Special = $false; SID = 'S-1-5-18'; LocalPath = 'C:\Users\Resolved' }
[PSCustomObject]@{ Special = $false; SID = 'S-1-5-21-0-0-0-1001'; LocalPath = 'C:\Users\Unresolved' }
)
}
Mock Test-Path { $true }
Mock Write-Warning {}
Mock Write-Host {}
Mock Get-AppRemovalMethod { 'WinGet' }
Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = -1978335107; Output = @() } }
Mock Invoke-WithTargetUserHive {}
Mock Get-WingetInstalledApps { return ,@() }
Mock Test-AppInWingetList { $false }
}
It 'keeps resolved profiles and Default while returning a SID translation failure' {
$result = Get-RunOnceWingetTargetUserNames
$result.Success | Should -BeFalse
$result.UserNames.Count | Should -Be 2
$result.UserNames | Should -Contain 'Default'
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like '*S-1-5-21-0-0-0-1001*will not be scheduled*' }
}
It 'returns failure when profile enumeration fails' {
Mock Get-CimInstance { throw 'enumeration failed' }
Mock Write-Error {}
$result = Get-RunOnceWingetTargetUserNames
$result.Success | Should -BeFalse
$result.UserNames.Count | Should -Be 0
}
It 'reports incomplete all-users removal even if the immediate inventory is empty' {
Remove-SelectedApps -appsList @('One.App') | Should -BeFalse
$script:AppRemovalFailures | Should -Be 1
$script:WingetDeferredRemovals.ContainsKey('One.App') | Should -BeFalse
Should -Invoke Invoke-WithTargetUserHive -Times 2 -Exactly
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Default' }
Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like 'WingetAllUsersUninstallDeferred*' }
}
It 'reports failure when required fallback scheduling throws during <Stage>' -ForEach @(
@{ Stage = 'target resolution' }
@{ Stage = 'profile enumeration' }
@{ Stage = 'task scheduling' }
) {
$ErrorActionPreference = 'Stop'
switch ($Stage) {
'target resolution' { Mock Get-RunOnceWingetTarget { throw 'target unavailable' } }
'profile enumeration' { Mock Get-CimInstance { throw 'enumeration failed' } }
'task scheduling' { Mock Set-RunOnceWingetTask { throw 'scheduling failed' } }
}
Remove-SelectedApps -appsList @('One.App') | Should -BeFalse
$script:AppRemovalFailures | Should -Be 1
$script:WingetDeferredRemovals.ContainsKey('One.App') | Should -BeFalse
Should -Invoke Get-WingetInstalledApps -Times 1 -Exactly
Should -Invoke Invoke-WithTargetUserHive -Times 0 -Exactly
}
}