Browse Source

test(lab): make the controller's setup and removal robust on dirty machines

The operating-system matrix runs on Windows Server 2019, 2022 and 2025 found
three defects in the fixture setup and removal of Invoke-NTFSSecurityLabTest.ps1:

- A native command's stderr that is redirected with 2>&1 is a terminating
  error in Windows PowerShell 5.1 under $ErrorActionPreference = 'Stop'. The
  first "The directory is not empty" line from PowerShell 7 ended the removal
  on Server 2019 before any retry. The removal is now a bounded loop whose
  PowerShell 7 command writes its errors to its output.
- Get-LocalGroupMember fails with "Failed to compare two elements in the
  array" when a group holds an orphaned SID, for example that of an account an
  earlier run deleted. The setup adds the members with Add-LocalGroupMember and
  ignores MemberExistsException instead of checking the members first.
- A user profile that is gone in the meantime no longer fails the client
  cleanup, and the retries are reported to the host.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: AI Assistant <ai@example.com>
pull/119/head
Raimund Andree 2 days ago
parent
commit
7d47316d09
  1. 90
      Tests/Lab/Invoke-NTFSSecurityLabTest.ps1

90
Tests/Lab/Invoke-NTFSSecurityLabTest.ps1

@ -466,25 +466,51 @@ $fileServerSetupScript = {
$null = New-LocalGroup -Name $LocalGroupName -Description 'NTFSSecurity live tests'
}
if ($SubjectSid -notin @(Get-LocalGroupMember -Name $LocalGroupName | ForEach-Object -Process { $_.SID.Value })) {
# Add the members and ignore the error for a member that exists. A check with Get-LocalGroupMember would fail with "Failed to
# compare two elements in the array" in Windows PowerShell 5.1 as soon as the group holds an orphaned SID, for example that of
# an account that an earlier run deleted.
try {
Add-LocalGroupMember -Name $LocalGroupName -Member $SubjectSid
}
catch {
if ($_.Exception.GetType().Name -ne 'MemberExistsException') {
throw
}
}
foreach ($sid in $AdministratorSid) {
if ($sid -notin @(Get-LocalGroupMember -SID 'S-1-5-32-544' | ForEach-Object -Process { $_.SID.Value })) {
try {
Add-LocalGroupMember -SID 'S-1-5-32-544' -Member $sid
}
catch {
if ($_.Exception.GetType().Name -ne 'MemberExistsException') {
throw
}
}
}
if (-not (Test-Path -LiteralPath $ShareLocalPath)) {
$null = New-Item -ItemType Directory -Path $ShareLocalPath
}
# The folders of earlier runs, also those with paths longer than 260 characters, which PowerShell 7 removes.
$command = '$ErrorActionPreference = ''Stop''; Get-ChildItem -LiteralPath ''{0}'' -Force | Remove-Item -Recurse -Force' -f $ShareLocalPath
$output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1
if ($LASTEXITCODE -ne 0) {
throw "The folders of earlier runs could not be removed: $output"
# The folders of earlier runs, also those with paths longer than 260 characters, which PowerShell 7 removes. A recursive removal can
# fail with "The directory is not empty" while another process, such as a virus scanner, still holds a handle to an item that was
# just deleted (seen on Windows Server 2019), so it is repeated. The command writes its errors to its output: a line on stderr would
# end this script at once, because the error action here is Stop and 2>&1 turns that line into a terminating error.
$command = '$errors = @(); Get-ChildItem -LiteralPath ''__PATH__'' -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable errors; $errors | ForEach-Object -Process { "$_" }'.Replace('__PATH__', $ShareLocalPath)
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))
$attempt = 0
do {
$attempt++
if ($attempt -gt 1) {
Start-Sleep -Seconds 5
}
$output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1
} while (@(Get-ChildItem -LiteralPath $ShareLocalPath -Force -ErrorAction SilentlyContinue).Count -gt 0 -and $attempt -lt 6)
if (@(Get-ChildItem -LiteralPath $ShareLocalPath -Force -ErrorAction SilentlyContinue).Count -gt 0) {
throw "The folders of earlier runs could not be removed in $attempt attempts: $output"
}
# Administrators and the system own the share; the delegated group may read it, and the folders of the cases grant
@ -524,9 +550,15 @@ $clientSetupScript = {
@{ Group = 'S-1-5-32-580'; Members = $RemoteUserSid }
)) {
foreach ($sid in $membership.Members) {
if ($sid -notin @(Get-LocalGroupMember -SID $membership.Group | ForEach-Object -Process { $_.SID.Value })) {
# See the file server setup: Get-LocalGroupMember fails on an orphaned SID.
try {
Add-LocalGroupMember -SID $membership.Group -Member $sid
}
catch {
if ($_.Exception.GetType().Name -ne 'MemberExistsException') {
throw
}
}
}
}
@ -865,12 +897,28 @@ $removeFileServerScript = {
}
foreach ($path in $ShareLocalPath, $PayloadPath) {
# PowerShell 7 removes the symbolic links of the tests without following them, which Windows PowerShell 5.1 doesn't do. A recursive
# removal can still fail with "The directory is not empty" while another process, such as a virus scanner, holds a handle to an item
# that was just deleted (seen on Windows Server 2019); a moment later nothing is left. So the removal is repeated before it fails.
# The command writes its errors to its output: a line on stderr would end this script at once (see the setup of the file server).
$command = '$errors = @(); Remove-Item -LiteralPath ''__PATH__'' -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable errors; $errors | ForEach-Object -Process { "$_" }'.Replace('__PATH__', $path)
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))
$attempt = 0
while ((Test-Path -LiteralPath $path) -and $attempt -lt 6) {
$attempt++
if ($attempt -gt 1) {
Start-Sleep -Seconds 5
}
$output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1
}
if (Test-Path -LiteralPath $path) {
$command = '$ErrorActionPreference = ''Stop''; Remove-Item -LiteralPath ''{0}'' -Recurse -Force' -f $path
$output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1
if ($LASTEXITCODE -ne 0) {
throw "'$path' could not be removed: $output"
throw "'$path' could not be removed in $attempt attempts: $output"
}
if ($attempt -gt 1) {
"'$path' was removed in $attempt attempts."
}
}
@ -893,7 +941,18 @@ $removeClientScript = {
}
}
Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid } | Remove-CimInstance
# A profile that is gone in the meantime needs no removal; one that is still there after the error does.
foreach ($userProfile in @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid })) {
try {
Remove-CimInstance -InputObject $userProfile
}
catch {
if (Get-CimInstance -ClassName Win32_UserProfile -Filter ("SID = '{0}'" -f $userProfile.SID) -ErrorAction SilentlyContinue) {
throw
}
}
}
if (Test-Path -LiteralPath $PayloadPath) {
Remove-Item -LiteralPath $PayloadPath -Recurse -Force
}
@ -955,7 +1014,10 @@ if ($RemoveFixture) {
}
$accountSids = @(Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Read the accounts' -ScriptBlock $accountSidScript -ArgumentList $organizationalUnitName @labCommand)
$null = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Remove the share and the folders' -ScriptBlock $removeFileServerScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $accountSids @labCommand
$removed = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Remove the share and the folders' -ScriptBlock $removeFileServerScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $accountSids @labCommand
foreach ($message in @($removed)) {
Write-LabProgress "${FileServer}: $message"
}
$null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Remove the members and the folder' -ScriptBlock $removeClientScript -ArgumentList $payloadPath, $accountSids @labCommand
$null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Remove the accounts' -ScriptBlock $removeAccountScript -ArgumentList $organizationalUnitName @labCommand
foreach ($computer in $ForeignDomainController) {

Loading…
Cancel
Save