From f4a16e1bdf5a9174f14d442082f57a10592d508a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 18:30:59 +0000 Subject: [PATCH] test: make the stream rows independent of the error action, show the Init branch, and pin the dot rules The independent review of the delta found the tests below weak. The verbose and debug rows of PipelineControl ran their command without an error action; the CI runner sets Stop, under which the handler that reports a later command's exception as an item error ended the pipeline with it, so the mutations that stop recording the verbose or the debug exception escaped (M19 in two configurations, M20 in all four). The rows now say -ErrorAction SilentlyContinue. The Enable-Privileges tests for a script named NTFSSecurity.Init.ps1 could not fail for the branch they name when the module setting was true, because the module enables the privileges itself before the cmdlet runs; they now also assert the verbose message that only the cmdlet writes, and they disable the privileges before each test so that the child process inherits none. The first-nested-folder test asserts the first folder, not either. Set-TestNullDacl refuses an item that is a link, which the native call would follow out of the sandbox. A table pins the three outcomes that the filter documentation lists for a dot (Report.*, a trailing dot, an empty value), so a change of those rules is a decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ItemCmdlets.Tests.ps1 | 24 ++++++++++++++++++ Tests/Links.Tests.ps1 | 2 ++ Tests/PipelineControl.Tests.ps1 | 12 +++++---- Tests/Privileges.Tests.ps1 | 45 +++++++++++++++++++++++++++------ Tests/TestHelpers.Tests.ps1 | 19 ++++++++++++++ Tests/TestHelpers.psm1 | 11 ++++++-- 6 files changed, 98 insertions(+), 15 deletions(-) diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 518a722..ea1c99f 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -207,6 +207,30 @@ Describe 'Get-ChildItem2' { ($result.Name -join ',') | Should -BeExactly 'Two.dots.txt' } + # The names are matched twice, by the enumeration and by the cmdlet, and the rules for a dot differ from those of + # Get-ChildItem, where Report.* also returns Report. The documentation lists this as a limitation; these cases pin + # it, so that a change of the rules is a decision. Report* is the control: without a dot in the pattern, the names + # without a dot are returned. + It 'Should return for -Filter ""' -ForEach @( + @{ Filter = 'Report.*'; Expected = 'Report.txt'; Outcome = 'only the names with a dot' } + @{ Filter = 'Report*'; Expected = 'Report,Report.txt'; Outcome = 'the names with and without a dot' } + @{ Filter = 'Report.'; Expected = ''; Outcome = 'nothing' } + @{ Filter = 'Rep*.'; Expected = ''; Outcome = 'nothing' } + @{ Filter = '*.'; Expected = ''; Outcome = 'nothing' } + @{ Filter = ''; Expected = ''; Outcome = 'nothing' } + ) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterDotRules' -Directory + foreach ($name in 'Report', 'Report.txt', 'Other') { + $file = Join-Path -Path $folder -ChildPath $name + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value $name + } + + $result = @(Get-ChildItem2 -Path $folder -Filter $Filter -ErrorAction Stop) + + ($result.Name | Sort-Object) -join ',' | Should -BeExactly $Expected + } + It 'Should reject a null -Filter' { { Get-ChildItem2 -Path $tree -Filter $null -ErrorAction Stop } | Should -Throw -ErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' -ExpectedMessage "*'Filter'*" diff --git a/Tests/Links.Tests.ps1 b/Tests/Links.Tests.ps1 index ef2fd93..2a1908c 100644 --- a/Tests/Links.Tests.ps1 +++ b/Tests/Links.Tests.ps1 @@ -267,6 +267,8 @@ Describe 'Get-NTFSHardLink' { $readRights = 'ReadAttributes, ReadData, ReadPermissions' $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = $readRights; $currentUser = $readRights } + # Reading the data is refused. ReadAttributes and ReadPermissions are denied as well, but nothing shows that in + # every session: an elevated one was seen to read the permissions anyway. { Get-Content -LiteralPath $file -ErrorAction Stop } | Should -Throw $result = @(Get-NTFSHardLink -Path $file -ErrorVariable linkErrors -ErrorAction SilentlyContinue) diff --git a/Tests/PipelineControl.Tests.ps1 b/Tests/PipelineControl.Tests.ps1 index 32867ba..96a035c 100644 --- a/Tests/PipelineControl.Tests.ps1 +++ b/Tests/PipelineControl.Tests.ps1 @@ -288,7 +288,9 @@ BeforeAll { # The commands that write a verbose or a debug message inside the try of their loop, which the later command takes. # The first record that reaches Select-Object ends the pipeline there. The preference of the debug stream is set by - # Assert-StreamStop: the Debug switch would ask before every message. + # Assert-StreamStop: the Debug switch would ask before every message. The error action is named because the CI runner + # sets $ErrorActionPreference to Stop: a catch that reports the exception of the later command as an error of the + # item would then end the pipeline with it, and the test could not tell that catch from passing the exception on. $streamRuns = @{ 'Get-FileHash2/verbose' = @{ # The first path is a folder, which the cmdlet skips with a verbose message. @@ -297,16 +299,16 @@ BeforeAll { $context.File = New-TestSandboxItem -Sandbox $sandbox -Name 'Hashed' $context } - Run = { param ($Context) Get-FileHash2 -Path $Context.First, $Context.File -Verbose 4>&1 } + Run = { param ($Context) Get-FileHash2 -Path $Context.First, $Context.File -Verbose -ErrorAction SilentlyContinue 4>&1 } } 'Set-NTFSSecurityDescriptor/verbose' = @{ Prepare = $cases['Set-NTFSSecurityDescriptor'].Prepare - Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -Verbose 4>&1 } + Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -Verbose -ErrorAction SilentlyContinue 4>&1 } Untouched = $cases['Set-NTFSSecurityDescriptor'].Untouched } 'Set-NTFSOwner/debug' = @{ Prepare = { New-Pair } - Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $currentUser 5>&1 } + Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $currentUser -ErrorAction SilentlyContinue 5>&1 } } } @@ -545,7 +547,7 @@ Describe 'A later command and the error of a folder that Get-ChildItem2 cannot r $caught | Should -Not -BeNullOrEmpty $caught.FullyQualifiedErrorId | Should -BeLike 'DirUnauthorizedAccessError,*' - $caught.TargetObject | Should -BeIn $unreadable + $caught.TargetObject | Should -Be $unreadable[0] $listed | Should -BeNullOrEmpty } } diff --git a/Tests/Privileges.Tests.ps1 b/Tests/Privileges.Tests.ps1 index f07cf56..8d5e219 100644 --- a/Tests/Privileges.Tests.ps1 +++ b/Tests/Privileges.Tests.ps1 @@ -241,8 +241,10 @@ Describe 'Privileges when a later command takes the debug messages of the cmdlet # Enable-Privileges recognizes the script NTFSSecurity.Init.ps1, which a user adds to start the module, by its name: from # that script, it enables the privileges only for the module setting EnablePrivileges, from any other script always. Each -# test runs the script in a child process, which starts without enabled privileges, so that the privileges of this -# process stay as they are. +# test runs the script in a child process, which inherits the privilege states of this one (disabled here, see BeforeEach), +# so that the privileges of this process stay as they are. With the setting $true, the module enables the privileges +# itself before the cmdlet runs, so the state alone does not show that the cmdlet did: it also announces that in a verbose +# message. Describe 'Enable-Privileges in the script NTFSSecurity.Init.ps1' { BeforeAll { function Invoke-StartScript { @@ -256,24 +258,51 @@ Describe 'Enable-Privileges in the script NTFSSecurity.Init.ps1' { param ($ModulePath, $Setting) Import-Module -Name $ModulePath -ErrorAction Stop (Get-Module -Name NTFSSecurity).PrivateData['EnablePrivileges'] = ($Setting -eq 'True') -Enable-Privileges +$messages = @(Enable-Privileges -Verbose 4>&1 | ForEach-Object -Process { "$($_.Message)" }) +'ANNOUNCED:{0}' -f [bool] @($messages -like '*are now enabled giving you access*').Count 'BACKUP:{0}' -f (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Backup').PrivilegeState '@ - $output = & (Get-Process -Id $PID).Path -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -ModulePath ([IO.Path]::GetFullPath($modulePath)) -Setting $Setting - @($output | Where-Object -FilterScript { $_ -like 'BACKUP:*' }) -replace '^BACKUP:' + $output = @(& (Get-Process -Id $PID).Path -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -ModulePath ([IO.Path]::GetFullPath($modulePath)) -Setting $Setting) + [pscustomobject]@{ + Announced = @($output | Where-Object -FilterScript { $_ -like 'ANNOUNCED:*' }) -replace '^ANNOUNCED:' + Backup = @($output | Where-Object -FilterScript { $_ -like 'BACKUP:*' }) -replace '^BACKUP:' + } } } + BeforeEach { + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + + AfterEach { + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + It 'Should enable the privileges when the module setting EnablePrivileges is $true' -Skip:(-not $holdsPrivileges) { - Invoke-StartScript -ScriptName 'NTFSSecurity.Init.ps1' -Setting $true | Should -Be 'Enabled' + Get-BackupPrivilegeState | Should -Be 'Disabled' + + $result = Invoke-StartScript -ScriptName 'NTFSSecurity.Init.ps1' -Setting $true + + $result.Backup | Should -Be 'Enabled' + $result.Announced | Should -Be 'True' } It 'Should leave the privileges disabled when the module setting EnablePrivileges is $false' -Skip:(-not $holdsPrivileges) { - Invoke-StartScript -ScriptName 'NTFSSecurity.Init.ps1' -Setting $false | Should -Be 'Disabled' + Get-BackupPrivilegeState | Should -Be 'Disabled' + + $result = Invoke-StartScript -ScriptName 'NTFSSecurity.Init.ps1' -Setting $false + + $result.Backup | Should -Be 'Disabled' + $result.Announced | Should -Be 'False' } It 'Should enable the privileges in a script of another name also when the module setting EnablePrivileges is $false' -Skip:(-not $holdsPrivileges) { - Invoke-StartScript -ScriptName 'Other.ps1' -Setting $false | Should -Be 'Enabled' + Get-BackupPrivilegeState | Should -Be 'Disabled' + + $result = Invoke-StartScript -ScriptName 'Other.ps1' -Setting $false + + $result.Backup | Should -Be 'Enabled' + $result.Announced | Should -Be 'True' } } diff --git a/Tests/TestHelpers.Tests.ps1 b/Tests/TestHelpers.Tests.ps1 index 32bbd74..055f6ad 100644 --- a/Tests/TestHelpers.Tests.ps1 +++ b/Tests/TestHelpers.Tests.ps1 @@ -283,6 +283,25 @@ Describe 'Test helpers' { Should -Throw -ExpectedMessage 'Refusing to change*' } + # The native call follows a link, so a junction in the sandbox that points to another folder is refused as the + # item itself, not only as a folder of its path. + It 'Should refuse an item that is a link, which can point outside the sandbox' { + $otherSandbox = New-TestSandbox -Name 'Helpers' + try { + $link = Join-Path -Path $sandbox -ChildPath 'NullDaclLink' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + New-Item -ItemType Junction -Path $link -Value $otherSandbox | Out-Null + $before = (Get-Acl -LiteralPath $otherSandbox).Sddl + + { Set-TestNullDacl -Sandbox $sandbox -Path $link } | Should -Throw -ExpectedMessage '*because it is a link*' + + (Get-Acl -LiteralPath $otherSandbox).Sddl | Should -BeExactly $before + } + finally { + Remove-TestSandbox -Sandbox $otherSandbox + } + } + It 'Should throw its own error when Windows refuses' { $missing = Join-Path -Path $sandbox -ChildPath 'Missing.txt' diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 index 251a766..b7dae9e 100644 --- a/Tests/TestHelpers.psm1 +++ b/Tests/TestHelpers.psm1 @@ -364,6 +364,15 @@ function Set-TestNullDacl { ) Assert-TestSandboxPath -Sandbox $Sandbox -Path $Path + $location = (Get-Location -PSProvider FileSystem).ProviderPath + $fullName = [IO.Path]::GetFullPath([IO.Path]::Combine($location, $Path)) + # Assert-TestSandboxPath checks the folders of the path for links, not the item itself, and the native call follows a + # link: a junction to a folder outside the sandbox would give everyone every access to that folder. + $attributes = try { [IO.File]::GetAttributes($fullName) } catch { $null } + if ($null -ne $attributes -and ($attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Refusing to change '$fullName', because it is a link." + } + if (-not ('NtfsSecurityTests.NativeAcl' -as [type])) { Add-Type -TypeDefinition @' namespace NtfsSecurityTests @@ -385,8 +394,6 @@ namespace NtfsSecurityTests '@ } - $location = (Get-Location -PSProvider FileSystem).ProviderPath - $fullName = [IO.Path]::GetFullPath([IO.Path]::Combine($location, $Path)) $result = [NtfsSecurityTests.NativeAcl]::SetNullDacl($fullName) if ($result -ne 0) { throw "SetNamedSecurityInfo could not set a NULL DACL on '$fullName' (error $result)."