From bbac9ac289ad35739c1a4dccce33fa9ca2339a37 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:19:50 +0000 Subject: [PATCH] test: close the follow-ups of #110 that tests can reach - Remove-NTFSAccess and Remove-NTFSAudit with -RemoveSpecific are tested also with -Path, not only with -SecurityDescriptor. - Copy-Item2 and Move-Item2 name the destination of a folder in their verbose message, not only of a file. - The Enable-Privileges count compares with the privileges of the token, so that it passes as a basic user, whose token holds one; the tests of -PassThru restore the privilege states that they found. - The type name comparison of Get-FileHash2 is exact, the inherited-entry counts must be greater than zero, and the braces test (#3) checks the verbose message that raised the FormatException. - Remove-TestSandbox removes paths longer than 260 characters in Windows PowerShell, through the \\?\ prefix and rd, so the long-path test of Test-Path2 no longer cleans up itself; after a failed setup, it returns instead of stopping AfterAll with a binding error. Not reachable by a test: a second path whose SACL read fails while the Security privilege is enabled; a declined -Confirm takes the code path of -WhatIf. The tests that need a session without privileges get the CI run as a basic user. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Access.Tests.ps1 | 26 ++++++++++++++++-- Tests/Audit.Tests.ps1 | 21 +++++++++++++++ Tests/Inheritance.Tests.ps1 | 2 ++ Tests/ItemCmdlets.Tests.ps1 | 23 +++++++++++----- Tests/OutputTypes.Tests.ps1 | 22 ++++++++++++--- Tests/SecurityDescriptorSets.Tests.ps1 | 1 + Tests/TestHelpers.Tests.ps1 | 22 +++++++++++++++ Tests/TestHelpers.psm1 | 37 ++++++++++++++++++++++---- 8 files changed, 137 insertions(+), 17 deletions(-) diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 9df8bb3..6c34196 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -152,13 +152,15 @@ Describe 'Get-NTFSEffectiveAccess' { } Describe 'Get-NTFSOrphanedAccess' { - # Before 5.0.0, a path with braces stopped the cmdlet with a FormatException (#3). + # Before 5.0.0, a path with braces stopped the cmdlet with a FormatException (#3), which the verbose message raised. It 'Should read a folder whose name contains braces' { $braces = Join-Path -Path $sandbox -ChildPath ('{{Braces}}-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) Assert-TestSandboxPath -Sandbox $sandbox -Path $braces [IO.Directory]::CreateDirectory($braces) | Out-Null - { Get-NTFSOrphanedAccess -Path $braces -ErrorAction Stop } | Should -Not -Throw + $messages = @(Get-NTFSOrphanedAccess -Path $braces -Verbose -ErrorAction Stop 4>&1) + + $messages.Message | Should -Contain "Item $braces knows about 0 orphaned SIDs in its ACL" } BeforeAll { @@ -549,6 +551,26 @@ Describe 'Remove-NTFSAccess' { $rule | Should -Not -BeNullOrEmpty $rule.FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse } + + It 'Should keep an entry that does not match exactly, given the path' { + Add-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights Modify + + Remove-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights ReadData -RemoveSpecific -ErrorAction Stop + + $rules = @((Get-Acl -LiteralPath $removeFolder).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) + $rules | Should -HaveCount 1 + $rules[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::Modify) | Should -BeTrue + } + + It 'Should remove an entry that matches exactly, given the path' { + Add-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights Modify + + Remove-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights Modify -RemoveSpecific -ErrorAction Stop + + (Get-Acl -LiteralPath $removeFolder).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' } | Should -BeNullOrEmpty + } } } diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index a9bc2ef..d7dae3a 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -316,6 +316,25 @@ Describe 'Remove-NTFSAudit' { Get-EveryoneAuditRule | Should -BeNullOrEmpty } + + It 'Should keep an audit entry that does not match exactly, given the path' { + Add-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights Modify + + Remove-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights ReadData -RemoveSpecific -ErrorAction Stop + + $entries = @(Get-NTFSAudit -Path $removeFolder -ExcludeInherited | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' }) + $entries | Should -HaveCount 1 + $entries[0].AccessRights.ToString() | Should -BeLike '*Modify*' + } + + It 'Should remove an audit entry that matches exactly, given the path' { + Add-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights Modify + + Remove-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights Modify -RemoveSpecific -ErrorAction Stop + + Get-NTFSAudit -Path $removeFolder -ExcludeInherited | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' } | + Should -BeNullOrEmpty + } } Context 'With -PassThru' { @@ -415,6 +434,8 @@ Describe 'Clear-NTFSAudit' { ) $audit.SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | Should -BeNullOrEmpty $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count + # Without inherited entries, the test couldn't see them copied as explicit ones (#110). + $inheritedCount | Should -BeGreaterThan 0 Clear-NTFSAudit -Path $file -ErrorVariable clearErrors -ErrorAction SilentlyContinue diff --git a/Tests/Inheritance.Tests.ps1 b/Tests/Inheritance.Tests.ps1 index 102df95..50d3fce 100644 --- a/Tests/Inheritance.Tests.ps1 +++ b/Tests/Inheritance.Tests.ps1 @@ -127,6 +127,8 @@ Describe 'Set-NTFSInheritance' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'KeepAccess' Assert-TestSandboxPath -Sandbox $sandbox -Path $file $inheritedCount = @((Get-Acl -LiteralPath $file).Access | Where-Object -Property IsInherited).Count + # Without inherited entries, the test couldn't see them kept as explicit ones (#110). + $inheritedCount | Should -BeGreaterThan 0 Set-NTFSInheritance -Path $file -AccessInheritanceEnabled $false diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 835badb..ba6c75d 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -160,6 +160,20 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { $messages.Message | Should -Contain ("File '{0}' {1} to '{2}'" -f $first, $Verb, $target) } + It ' should name the destination of a folder in the verbose message' -ForEach @( + @{ Command = 'Copy-Item2'; Verb = 'copied' } + @{ Command = 'Move-Item2'; Verb = 'moved' } + ) { + $sourceFolder = Join-Path -Path $folder -ChildPath 'VerboseFolder' + Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceFolder + New-Item -ItemType Directory -Path $sourceFolder | Out-Null + $target = Join-Path -Path $destination -ChildPath 'VerboseFolder' + + $messages = & $Command -Path $sourceFolder -Destination $destination -Verbose 4>&1 + + $messages.Message | Should -Contain ("Directory '{0}' {1} to '{2}'" -f $sourceFolder, $Verb, $target) + } + # With -PassThru, both cmdlets return the item at the destination, as their pages say. It 'Copy-Item2 -PassThru should return the copy' { $result = Copy-Item2 -Path $first -Destination $destination -PassThru $true @@ -381,14 +395,9 @@ Describe 'Test-Path2' { $long = Join-Path -Path $longRoot -ChildPath (('A' * 100), ('B' * 100), ('C' * 100) -join '\') Add-Type -Path (Join-Path -Path (Get-Module -Name NTFSSecurity).ModuleBase -ChildPath 'AlphaFS.dll') [Alphaleonis.Win32.Filesystem.Directory]::CreateDirectory($long) | Out-Null - try { - $long.Length | Should -BeGreaterThan 260 + $long.Length | Should -BeGreaterThan 260 - Test-Path2 -Path $long -PathType Container -ErrorAction Stop | Should -BeTrue - } finally { - # Remove-TestSandbox can't delete paths longer than 260 characters (#110). - [Alphaleonis.Win32.Filesystem.Directory]::Delete($longRoot, $true) - } + Test-Path2 -Path $long -PathType Container -ErrorAction Stop | Should -BeTrue } # Before 5.0.0-rc6, a path with a character that Windows doesn't allow in file names stopped the cmdlet with a diff --git a/Tests/OutputTypes.Tests.ps1 b/Tests/OutputTypes.Tests.ps1 index 0286333..e6bf35c 100644 --- a/Tests/OutputTypes.Tests.ps1 +++ b/Tests/OutputTypes.Tests.ps1 @@ -56,20 +56,36 @@ Describe 'Declared output types' { $result = Get-FileHash2 -Path $file - $result.PSObject.TypeNames[0] | Should -Be @((Get-Command -Name Get-FileHash2).OutputType.Name)[0] + $result.PSObject.TypeNames[0] | Should -BeExactly @((Get-Command -Name Get-FileHash2).OutputType.Name)[0] } } Describe 'Privilege cmdlets with -PassThru' { + BeforeAll { + # The tests enable and disable the privileges of the test process; AfterAll restores the states they had (#110). + $fileSystemPrivileges = 'TakeOwnership', 'Restore', 'Backup', 'Security' + $enabledBefore = @(Get-Privileges | Where-Object -FilterScript { + $_.Privilege.ToString() -in $fileSystemPrivileges -and $_.PrivilegeState -eq 'Enabled' + } | ForEach-Object -Process { $_.Privilege }) + } + AfterEach { Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue } - # Before 5.0.0, -PassThru wrote the privileges as one collection. + AfterAll { + $process = [System.Diagnostics.Process]::GetCurrentProcess() + foreach ($privilege in $enabledBefore) { + $null = [ProcessPrivileges.ProcessExtensions]::EnablePrivilege($process, $privilege) + } + } + + # Before 5.0.0, -PassThru wrote the privileges as one collection. The access token of a basic user holds one + # privilege only, so the test compares with the privileges that the token holds. It 'Enable-Privileges should write one object per privilege' { $result = @(Enable-Privileges -PassThru -ErrorAction SilentlyContinue) - $result.Count | Should -BeGreaterThan 1 + $result | Should -HaveCount @(Get-Privileges).Count $result | ForEach-Object -Process { $_ | Should -BeOfType [ProcessPrivileges.PrivilegeAndAttributes] } } diff --git a/Tests/SecurityDescriptorSets.Tests.ps1 b/Tests/SecurityDescriptorSets.Tests.ps1 index 361591f..a8d4a8a 100644 --- a/Tests/SecurityDescriptorSets.Tests.ps1 +++ b/Tests/SecurityDescriptorSets.Tests.ps1 @@ -53,6 +53,7 @@ Describe 'Cmdlets that change a security descriptor in memory' { It 'Disable-NTFSAccessInheritance should protect the DACL of the descriptor and keep the inherited entries' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'DisableAccess' $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count + $inheritedCount | Should -BeGreaterThan 0 $sd = Get-NTFSSecurityDescriptor -Path $file Disable-NTFSAccessInheritance -SecurityDescriptor $sd -ErrorAction Stop diff --git a/Tests/TestHelpers.Tests.ps1 b/Tests/TestHelpers.Tests.ps1 index 0b569c8..0969633 100644 --- a/Tests/TestHelpers.Tests.ps1 +++ b/Tests/TestHelpers.Tests.ps1 @@ -134,6 +134,28 @@ Describe 'Test helpers' { It 'Should not change the ACL of the target of a junction' { (Get-Acl -LiteralPath $target).Sddl | Should -BeExactly $targetSddl } + + # Before 5.0.0-rc6, the teardown couldn't remove paths longer than 260 characters in Windows PowerShell (#110). + It 'Should remove a sandbox with a path longer than 260 characters' { + $longSandbox = New-TestSandbox -Name 'Helpers' + $long = Join-Path -Path $longSandbox -ChildPath (('A' * 100), ('B' * 100), ('C' * 100) -join '\') + Assert-TestSandboxPath -Sandbox $longSandbox -Path $long + # The \\?\ prefix lets Windows PowerShell create the path. + [IO.Directory]::CreateDirectory('\\?\' + $long) | Out-Null + [IO.File]::WriteAllText(('\\?\' + $long + '\File.txt'), 'Long') + $long.Length | Should -BeGreaterThan 260 + + Remove-TestSandbox -Sandbox $longSandbox + + $longSandbox | Should -Not -Exist + } + + # Before 5.0.0-rc6, an AfterAll after a failed setup stopped with a binding error that hid the error of the setup. + It 'Should do nothing for a sandbox that a failed setup did not create: <_>' -ForEach @('$null', 'empty string') { + $value = if ($_ -eq '$null') { $null } else { '' } + + { Remove-TestSandbox -Sandbox $value } | Should -Not -Throw + } } Context 'Set-TestOwner' { diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 index e956790..277a485 100644 --- a/Tests/TestHelpers.psm1 +++ b/Tests/TestHelpers.psm1 @@ -87,10 +87,18 @@ function Remove-TestSandbox { [CmdletBinding()] param ( [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyString()] [string] $Sandbox ) + # A setup that failed before New-TestSandbox returned leaves nothing to remove. Before 5.0.0-rc6, the binding error + # hid the error of the setup (#110). + if ([string]::IsNullOrEmpty($Sandbox)) { + return + } + Assert-TestSandboxPath -Sandbox $Sandbox -Path $Sandbox if (-not (Test-Path -LiteralPath $Sandbox)) { return @@ -100,17 +108,20 @@ function Remove-TestSandbox { # the caller uses ErrorAction Stop. Such items can still be deleted through the rights on their folder. $ErrorActionPreference = 'Continue' + # The prefix lets .NET in Windows PowerShell reach paths longer than 260 characters (#110). + $longPathPrefix = '\\?\' + # Windows PowerShell 5.1 and icacls /T follow directory links, so the links go first. A folder that denies # listing its content gets its own ACL reset, without /T, before it is listed. $pending = New-Object -TypeName 'System.Collections.Generic.Stack[string]' - $pending.Push($Sandbox) + $pending.Push($longPathPrefix + $Sandbox) while ($pending.Count -gt 0) { $folder = $pending.Pop() try { $entries = [IO.Directory]::GetFileSystemEntries($folder) } catch { - & icacls.exe $folder /reset /C /Q *> $null + & icacls.exe $folder.Substring($longPathPrefix.Length) /reset /C /Q *> $null $entries = [IO.Directory]::GetFileSystemEntries($folder) } foreach ($entry in $entries) { @@ -129,10 +140,26 @@ function Remove-TestSandbox { } } & icacls.exe $Sandbox /reset /T /C /Q *> $null - Get-ChildItem -LiteralPath $Sandbox -Recurse -Force | ForEach-Object -Process { - $_.Attributes = [IO.FileAttributes]::Normal + Get-ChildItem -LiteralPath $Sandbox -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object -Process { + # Windows PowerShell returns items below paths longer than 260 characters that it can't change; rd removes them. + try { + $_.Attributes = [IO.FileAttributes]::Normal + } + catch { + Write-Verbose -Message "Keeping the attributes of '$($_.FullName)': $($_.Exception.Message)" + } + } + Remove-Item -LiteralPath $Sandbox -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $Sandbox) { + # Windows PowerShell can't remove paths longer than 260 characters; rd can with the prefix, and the links are + # gone already. + & cmd.exe /d /c ('rd /s /q "{0}{1}"' -f $longPathPrefix, $Sandbox) *> $null } - Remove-Item -LiteralPath $Sandbox -Recurse -Force + + if (Test-Path -LiteralPath $Sandbox) { + Write-Error -Message "The sandbox '$Sandbox' could not be removed." + } + try { # Fails while another sandbox exists, also one of a test run in parallel [IO.Directory]::Delete($script:sandboxRoot, $false)