mirror of https://github.com/raandree/NTFSSecurity
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
638 lines
31 KiB
638 lines
31 KiB
<#
|
|
Tests how the cmdlets of the module built in NTFSSecurity\bin\Release behave when a later command in the pipeline
|
|
ends it: a break or continue in a script block, Select-Object -First, or a terminating error such as a throw. The
|
|
exception that carries it passes through the cmdlet while it writes an object, an error, a verbose message, or a debug
|
|
message. A catch-all for the failures of an item must not report it as an error of that item and go on with the next
|
|
one: a cmdlet that removes, copies, moves, or changes items would change them all, although the caller ended the
|
|
pipeline, and the caller would never see the exception. Every test works on files and folders in a sandbox.
|
|
#>
|
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
|
|
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
|
|
)]
|
|
param ()
|
|
|
|
BeforeDiscovery {
|
|
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
|
|
$canReadAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
|
|
|
|
$names = @(
|
|
'Get-ChildItem2', 'Get-DiskSpace', 'Get-FileHash2', 'Get-Item2', 'Get-NTFSAccess', 'Get-NTFSEffectiveAccess',
|
|
'Get-NTFSHardLink', 'Get-NTFSInheritance', 'Get-NTFSOrphanedAccess', 'Get-NTFSOwner', 'Get-NTFSSecurityDescriptor',
|
|
'Get-NTFSSimpleAccess', 'Get-Privileges', 'Test-Path2', 'Add-NTFSAccess', 'Remove-NTFSAccess',
|
|
'Disable-NTFSAccessInheritance', 'Enable-NTFSAccessInheritance', 'Set-NTFSInheritance', 'Set-NTFSOwner',
|
|
'Set-NTFSSecurityDescriptor', 'Copy-Item2', 'Move-Item2', 'Remove-Item2'
|
|
)
|
|
$auditNames = @(
|
|
'Get-NTFSAudit', 'Get-NTFSOrphanedAudit', 'Add-NTFSAudit', 'Remove-NTFSAudit', 'Disable-NTFSAuditInheritance',
|
|
'Enable-NTFSAuditInheritance'
|
|
)
|
|
$loopCases = foreach ($name in $names) {
|
|
foreach ($keyword in 'break', 'continue') {
|
|
@{ Name = $name; Keyword = $keyword }
|
|
}
|
|
}
|
|
$stopCases = foreach ($name in $names) {
|
|
@{ Name = $name }
|
|
}
|
|
$auditLoopCases = foreach ($name in $auditNames) {
|
|
foreach ($keyword in 'break', 'continue') {
|
|
@{ Name = $name; Keyword = $keyword }
|
|
}
|
|
}
|
|
$auditStopCases = foreach ($name in $auditNames) {
|
|
@{ Name = $name }
|
|
}
|
|
$failureCases = foreach ($name in $names) {
|
|
foreach ($style in 'throw', 'Write-Error -ErrorAction Stop') {
|
|
@{ Name = $name; Style = $style }
|
|
}
|
|
}
|
|
$auditFailureCases = foreach ($name in $auditNames) {
|
|
foreach ($style in 'throw', 'Write-Error -ErrorAction Stop') {
|
|
@{ Name = $name; Style = $style }
|
|
}
|
|
}
|
|
$streamCases = foreach ($case in @(
|
|
@{ Name = 'Get-FileHash2'; Stream = 'verbose' }
|
|
@{ Name = 'Set-NTFSSecurityDescriptor'; Stream = 'verbose' }
|
|
@{ Name = 'Set-NTFSOwner'; Stream = 'debug' }
|
|
)) {
|
|
foreach ($style in 'Select-Object -First 1', 'throw') {
|
|
@{ Name = $case.Name; Stream = $case.Stream; Style = $style }
|
|
}
|
|
}
|
|
}
|
|
|
|
BeforeAll {
|
|
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
|
|
$modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1'
|
|
Import-Module -Name $modulePath -Force -ErrorAction Stop
|
|
$sandbox = New-TestSandbox -Name 'PipelineControl'
|
|
Push-Location -LiteralPath $sandbox
|
|
|
|
$sidType = [System.Security.Principal.SecurityIdentifier]
|
|
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
$orphan = 'S-1-5-21-1-2-3-1001'
|
|
$privateData = (Get-Module -Name NTFSSecurity).PrivateData
|
|
|
|
function New-Pair {
|
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
|
|
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only writes to the sandbox.'
|
|
)]
|
|
param ([switch] $Directory)
|
|
|
|
@{
|
|
First = New-TestSandboxItem -Sandbox $sandbox -Name 'First' -Directory:$Directory
|
|
Second = New-TestSandboxItem -Sandbox $sandbox -Name 'Second' -Directory:$Directory
|
|
}
|
|
}
|
|
|
|
function Test-ExplicitEntry {
|
|
param ([string] $Path, [string] $Account)
|
|
|
|
@((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, $sidType) |
|
|
Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }).Count -gt 0
|
|
}
|
|
|
|
# Each case runs one command over the two items of its context. Untouched tells whether the second item is as it
|
|
# was, which it is only when the command stopped after the first one.
|
|
$cases = @{
|
|
'Get-ChildItem2' = @{
|
|
# The first file is two levels below the folder, so the exception passes the frames of the recursion.
|
|
Prepare = {
|
|
$top = New-TestSandboxItem -Sandbox $sandbox -Name 'Tree' -Directory
|
|
foreach ($relative in 'A\B\Two.txt', 'C\Three.txt') {
|
|
$file = Join-Path -Path $top -ChildPath $relative
|
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
|
|
New-Item -ItemType Directory -Path (Split-Path -Path $file -Parent) -Force | Out-Null
|
|
Set-Content -LiteralPath $file -Value 'Tree'
|
|
}
|
|
@{ Top = $top }
|
|
}
|
|
Run = { param ($Context) Get-ChildItem2 -Path $Context.Top -Recurse -File -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-DiskSpace' = @{
|
|
Prepare = { @{} }
|
|
Run = { Get-DiskSpace -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-FileHash2' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Get-FileHash2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-Item2' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Get-Item2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-NTFSAccess' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Get-NTFSAccess -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-NTFSEffectiveAccess' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Get-NTFSEffectiveAccess -Path $Context.First, $Context.Second -WarningAction SilentlyContinue -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-NTFSHardLink' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Get-NTFSHardLink -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-NTFSInheritance' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Get-NTFSInheritance -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-NTFSOrphanedAccess' = @{
|
|
Prepare = {
|
|
$context = New-Pair
|
|
Add-NTFSAccess -Path $context.First, $context.Second -Account $orphan -AccessRights ReadData -ErrorAction Stop
|
|
$context
|
|
}
|
|
Run = { param ($Context) Get-NTFSOrphanedAccess -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-NTFSOwner' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Get-NTFSOwner -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-NTFSSecurityDescriptor' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Get-NTFSSecurityDescriptor -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-NTFSSimpleAccess' = @{
|
|
Prepare = { New-Pair -Directory }
|
|
Run = { param ($Context) Get-NTFSSimpleAccess -Path $Context.First, $Context.Second -IncludeRootFolder:$false -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-Privileges' = @{
|
|
Prepare = { @{} }
|
|
Run = { Get-Privileges -ErrorAction SilentlyContinue }
|
|
}
|
|
'Test-Path2' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Test-Path2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Add-NTFSAccess' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Add-NTFSAccess -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights ReadData -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) -not (Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0') }
|
|
}
|
|
'Remove-NTFSAccess' = @{
|
|
Prepare = {
|
|
$context = New-Pair
|
|
Add-NTFSAccess -Path $context.First, $context.Second -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop
|
|
$context
|
|
}
|
|
Run = { param ($Context) Remove-NTFSAccess -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights ReadData -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0' }
|
|
}
|
|
'Disable-NTFSAccessInheritance' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Disable-NTFSAccessInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) -not (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected }
|
|
}
|
|
'Enable-NTFSAccessInheritance' = @{
|
|
Prepare = {
|
|
$context = New-Pair
|
|
Disable-NTFSAccessInheritance -Path $context.First, $context.Second -ErrorAction Stop
|
|
$context
|
|
}
|
|
Run = { param ($Context) Enable-NTFSAccessInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected }
|
|
}
|
|
'Set-NTFSInheritance' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Set-NTFSInheritance -Path $Context.First, $Context.Second -AccessInheritanceEnabled $false -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) -not (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected }
|
|
}
|
|
'Set-NTFSOwner' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $currentUser -PassThru -ErrorAction SilentlyContinue }
|
|
}
|
|
'Set-NTFSSecurityDescriptor' = @{
|
|
Prepare = {
|
|
$context = New-Pair
|
|
$context.Descriptors = @(Get-NTFSSecurityDescriptor -Path $context.First, $context.Second -ErrorAction Stop)
|
|
Add-NTFSAccess -SecurityDescriptor $context.Descriptors -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop
|
|
$context
|
|
}
|
|
Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) -not (Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0') }
|
|
}
|
|
'Copy-Item2' = @{
|
|
Prepare = {
|
|
$context = New-Pair
|
|
$context.Destination = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyTo' -Directory
|
|
$context
|
|
}
|
|
Run = { param ($Context) Copy-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) -not (Test-Path -LiteralPath (Join-Path -Path $Context.Destination -ChildPath (Split-Path -Path $Context.Second -Leaf))) }
|
|
}
|
|
'Move-Item2' = @{
|
|
Prepare = {
|
|
$context = New-Pair
|
|
$context.Destination = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveTo' -Directory
|
|
$context
|
|
}
|
|
Run = { param ($Context) Move-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second }
|
|
}
|
|
'Remove-Item2' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Remove-Item2 -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second }
|
|
}
|
|
'Get-NTFSAudit' = @{
|
|
Prepare = {
|
|
$context = New-Pair
|
|
Add-NTFSAudit -Path $context.First, $context.Second -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -ErrorAction Stop
|
|
$context
|
|
}
|
|
Run = { param ($Context) Get-NTFSAudit -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Get-NTFSOrphanedAudit' = @{
|
|
Prepare = {
|
|
$context = New-Pair
|
|
Add-NTFSAudit -Path $context.First, $context.Second -Account $orphan -AccessRights Delete -AuditFlags Success -ErrorAction Stop
|
|
$context
|
|
}
|
|
Run = { param ($Context) Get-NTFSOrphanedAudit -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
|
|
}
|
|
'Add-NTFSAudit' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Add-NTFSAudit -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) @(Get-NTFSAudit -Path $Context.Second -ErrorAction Stop).Count -eq 0 }
|
|
}
|
|
'Remove-NTFSAudit' = @{
|
|
# The entry of the orphan goes; the one of Everyone stays, so that there is an object to write.
|
|
Prepare = {
|
|
$context = New-Pair
|
|
foreach ($account in $orphan, 'S-1-1-0') {
|
|
Add-NTFSAudit -Path $context.First, $context.Second -Account $account -AccessRights Delete -AuditFlags Success -ErrorAction Stop
|
|
}
|
|
$context
|
|
}
|
|
Run = { param ($Context) Remove-NTFSAudit -Path $Context.First, $Context.Second -Account $orphan -AccessRights Delete -AuditFlags Success -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) @(Get-NTFSAudit -Path $Context.Second -Account $orphan -ErrorAction Stop).Count -gt 0 }
|
|
}
|
|
'Disable-NTFSAuditInheritance' = @{
|
|
Prepare = { New-Pair }
|
|
Run = { param ($Context) Disable-NTFSAuditInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) (Get-NTFSInheritance -Path $Context.Second -ErrorAction Stop).AuditInheritanceEnabled }
|
|
}
|
|
'Enable-NTFSAuditInheritance' = @{
|
|
Prepare = {
|
|
$context = New-Pair
|
|
Disable-NTFSAuditInheritance -Path $context.First, $context.Second -ErrorAction Stop
|
|
$context
|
|
}
|
|
Run = { param ($Context) Enable-NTFSAuditInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
|
|
Untouched = { param ($Context) -not (Get-NTFSInheritance -Path $Context.Second -ErrorAction Stop).AuditInheritanceEnabled }
|
|
}
|
|
}
|
|
|
|
# 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. 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.
|
|
Prepare = {
|
|
$context = New-Pair -Directory
|
|
$context.File = New-TestSandboxItem -Sandbox $sandbox -Name 'Hashed'
|
|
$context
|
|
}
|
|
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 -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 -ErrorAction SilentlyContinue 5>&1 }
|
|
}
|
|
}
|
|
|
|
# The command writes its first object, and the break or continue of the later command ends the loop around the
|
|
# pipeline before the next statement of the loop runs.
|
|
function Assert-LoopControl {
|
|
param ([string] $Name, [string] $Keyword)
|
|
|
|
$case = $cases[$Name]
|
|
$context = & $case.Prepare
|
|
$emitted = 0
|
|
$reachedEnd = $false
|
|
$Error.Clear()
|
|
foreach ($round in 1) {
|
|
& $case.Run $context | ForEach-Object -Process {
|
|
$emitted++
|
|
if ($Keyword -eq 'break') { break } else { continue }
|
|
}
|
|
$reachedEnd = $true
|
|
}
|
|
|
|
$emitted | Should -Be 1
|
|
$reachedEnd | Should -BeFalse
|
|
$Error.Count | Should -Be 0
|
|
if ($case.Untouched) {
|
|
(& $case.Untouched $context) | Should -BeTrue
|
|
}
|
|
}
|
|
|
|
function Assert-PipelineStop {
|
|
param ([string] $Name)
|
|
|
|
$case = $cases[$Name]
|
|
$context = & $case.Prepare
|
|
$Error.Clear()
|
|
|
|
$result = @(& $case.Run $context | Select-Object -First 1)
|
|
|
|
$result | Should -HaveCount 1
|
|
$Error.Count | Should -Be 0
|
|
if ($case.Untouched) {
|
|
(& $case.Untouched $context) | Should -BeTrue
|
|
}
|
|
}
|
|
|
|
# A later command that fails with a terminating error ends the pipeline for the commands before it. The error is the
|
|
# caller's: the cmdlet must neither report it as an error of an item nor go on with the next item. PowerShell wraps
|
|
# the exception of a throw, so the cmdlet never sees the type that was thrown.
|
|
function Assert-DownstreamFailure {
|
|
param ([string] $Name, [string] $Style)
|
|
|
|
$case = $cases[$Name]
|
|
$context = & $case.Prepare
|
|
$emitted = 0
|
|
$caught = $null
|
|
$Error.Clear()
|
|
try {
|
|
& $case.Run $context | ForEach-Object -Process {
|
|
$emitted++
|
|
if ($Style -eq 'throw') { throw 'Downstream failure' }
|
|
Write-Error -Message 'Downstream failure' -ErrorAction Stop
|
|
}
|
|
}
|
|
catch {
|
|
$caught = $_
|
|
}
|
|
|
|
$caught.Exception.Message | Should -BeLike '*Downstream failure*'
|
|
$emitted | Should -Be 1
|
|
@($Error | Where-Object -FilterScript { $_.Exception.Message -notlike '*Downstream failure*' }) | Should -BeNullOrEmpty
|
|
if ($case.Untouched) {
|
|
(& $case.Untouched $context) | Should -BeTrue
|
|
}
|
|
}
|
|
|
|
# The first verbose or debug record reaches the later command, which ends the pipeline inside the try of the loop:
|
|
# Select-Object raises the end of the pipeline, a throw raises an exception of its own. With the privileges enabled,
|
|
# the cmdlet writes a message before that, outside the try, so they stay off here.
|
|
function Assert-StreamStop {
|
|
param ([string] $Name, [string] $Stream, [string] $Style)
|
|
|
|
$case = $streamRuns["$Name/$Stream"]
|
|
$recordType = if ($Stream -eq 'debug') { [System.Management.Automation.DebugRecord] } else { [System.Management.Automation.VerboseRecord] }
|
|
$context = & $case.Prepare
|
|
$saved = $privateData['EnablePrivileges']
|
|
$savedDebugPreference = $DebugPreference
|
|
$privateData['EnablePrivileges'] = $false
|
|
$DebugPreference = if ($Stream -eq 'debug') { 'Continue' } else { $savedDebugPreference }
|
|
$emitted = 0
|
|
$caught = $null
|
|
$result = @()
|
|
$Error.Clear()
|
|
try {
|
|
if ($Style -eq 'throw') {
|
|
try {
|
|
& $case.Run $context | ForEach-Object -Process {
|
|
$emitted++
|
|
throw 'Downstream failure'
|
|
}
|
|
}
|
|
catch {
|
|
$caught = $_
|
|
}
|
|
}
|
|
else {
|
|
$result = @(& $case.Run $context | Select-Object -First 1)
|
|
}
|
|
}
|
|
finally {
|
|
$privateData['EnablePrivileges'] = $saved
|
|
$DebugPreference = $savedDebugPreference
|
|
}
|
|
|
|
if ($Style -eq 'throw') {
|
|
$caught.Exception.Message | Should -BeLike '*Downstream failure*'
|
|
$emitted | Should -Be 1
|
|
@($Error | Where-Object -FilterScript { $_.Exception.Message -notlike '*Downstream failure*' }) | Should -BeNullOrEmpty
|
|
}
|
|
else {
|
|
$result | Should -HaveCount 1
|
|
$result[0] | Should -BeOfType $recordType
|
|
$Error.Count | Should -Be 0
|
|
}
|
|
|
|
if ($case.Untouched) {
|
|
(& $case.Untouched $context) | Should -BeTrue
|
|
}
|
|
}
|
|
}
|
|
|
|
AfterAll {
|
|
Pop-Location
|
|
Remove-TestSandbox -Sandbox $sandbox
|
|
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
Describe 'A later command that ends the pipeline' {
|
|
It '<Name> should leave the loop for <Keyword> after its first object and change nothing else' -ForEach $loopCases {
|
|
Assert-LoopControl -Name $Name -Keyword $Keyword
|
|
}
|
|
|
|
It '<Name> should stop after the first object for Select-Object -First 1 and change nothing else' -ForEach $stopCases {
|
|
Assert-PipelineStop -Name $Name
|
|
}
|
|
|
|
It '<Name> should leave the loop for <Keyword> after its first object and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditLoopCases {
|
|
Assert-LoopControl -Name $Name -Keyword $Keyword
|
|
}
|
|
|
|
It '<Name> should stop after the first object for Select-Object -First 1 and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditStopCases {
|
|
Assert-PipelineStop -Name $Name
|
|
}
|
|
|
|
It '<Name> should stop for a terminating error (<Style>) of the later command and change nothing else' -ForEach $failureCases {
|
|
Assert-DownstreamFailure -Name $Name -Style $Style
|
|
}
|
|
|
|
It '<Name> should stop for a terminating error (<Style>) of the later command and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditFailureCases {
|
|
Assert-DownstreamFailure -Name $Name -Style $Style
|
|
}
|
|
|
|
It '<Name> should stop at the <Stream> message for <Style> of the later command and change nothing else' -ForEach $streamCases {
|
|
Assert-StreamStop -Name $Name -Stream $Stream -Style $Style
|
|
}
|
|
}
|
|
|
|
# The errors that Get-ChildItem2 writes for a folder that it cannot read reach a later command too, for example with 2>&1.
|
|
# The folders that cannot be read come first in the order of the file system, so that the folder with the file is reached
|
|
# only if the listing goes on after the first error.
|
|
Describe 'A later command and the error of a folder that Get-ChildItem2 cannot read' {
|
|
BeforeAll {
|
|
$errorTree = New-TestSandboxItem -Sandbox $sandbox -Name 'ErrorTree' -Directory
|
|
$unreadable = foreach ($name in 'A', 'B') {
|
|
$folder = Join-Path -Path $errorTree -ChildPath $name
|
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $folder
|
|
New-Item -ItemType Directory -Path $folder | Out-Null
|
|
$folder
|
|
}
|
|
$readableFile = Join-Path -Path $errorTree -ChildPath 'C\Three.txt'
|
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $readableFile
|
|
New-Item -ItemType Directory -Path (Split-Path -Path $readableFile -Parent) | Out-Null
|
|
Set-Content -LiteralPath $readableFile -Value 'Three'
|
|
foreach ($folder in $unreadable) {
|
|
Add-TestDenyRule -Sandbox $sandbox -Path $folder -Rights @{ 'S-1-1-0' = 'ReadData' }
|
|
}
|
|
}
|
|
|
|
# Before 5.0.0-rc7, the recursion took what the later command threw for the error of a nested folder as a failure of
|
|
# the folder above it, wrote a verbose message, and left the loop over the folders: the listing ended early and the
|
|
# caller never saw the exception. The error action is named because the CI runner sets $ErrorActionPreference to Stop,
|
|
# which would end the listing at the first error before the later command saw it.
|
|
It 'Should pass on what a later command throws when it takes the error of a nested folder' {
|
|
$emitted = 0
|
|
$caught = $null
|
|
try {
|
|
Get-ChildItem2 -Path $errorTree -Recurse -File -ErrorAction Continue 2>&1 | ForEach-Object -Process {
|
|
$emitted++
|
|
throw 'Downstream failure'
|
|
}
|
|
}
|
|
catch {
|
|
$caught = $_
|
|
}
|
|
|
|
$caught.Exception.Message | Should -BeLike '*Downstream failure*'
|
|
$emitted | Should -Be 1
|
|
}
|
|
|
|
It 'Should leave the loop for a <Keyword> of a later command that takes the error of a nested folder' -ForEach @(
|
|
@{ Keyword = 'break' }
|
|
@{ Keyword = 'continue' }
|
|
) {
|
|
$emitted = 0
|
|
$reachedEnd = $false
|
|
foreach ($round in 1) {
|
|
Get-ChildItem2 -Path $errorTree -Recurse -File -ErrorAction Continue 2>&1 | ForEach-Object -Process {
|
|
$emitted++
|
|
if ($Keyword -eq 'break') { break } else { continue }
|
|
}
|
|
$reachedEnd = $true
|
|
}
|
|
|
|
$emitted | Should -Be 1
|
|
$reachedEnd | Should -BeFalse
|
|
}
|
|
|
|
It 'Should stop with the error of the first nested folder that it cannot read for -ErrorAction Stop' {
|
|
$listed = New-Object -TypeName 'System.Collections.Generic.List[object]'
|
|
$caught = $null
|
|
try {
|
|
Get-ChildItem2 -Path $errorTree -Recurse -File -ErrorAction Stop | ForEach-Object -Process { $listed.Add($_) }
|
|
}
|
|
catch {
|
|
$caught = $_
|
|
}
|
|
|
|
$caught | Should -Not -BeNullOrEmpty
|
|
$caught.FullyQualifiedErrorId | Should -BeLike 'DirUnauthorizedAccessError,*'
|
|
$caught.TargetObject | Should -Be $unreadable[0]
|
|
$listed | Should -BeNullOrEmpty
|
|
}
|
|
}
|
|
|
|
# The catches of Get-ChildItem2 for an UnauthorizedAccessException and of Remove-Item2 for an IOException don't ask where
|
|
# the exception comes from: they rely on PowerShell wrapping what a later command throws, so that an exception of these
|
|
# types never reaches them as it was thrown. A PowerShell version that hands it on as it is fails these tests.
|
|
Describe 'A later command that throws an exception of a type that a cmdlet handles' {
|
|
It 'Get-ChildItem2 should pass on a thrown UnauthorizedAccessException' {
|
|
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ThrownDenied' -Directory
|
|
$files = 'One.txt', 'Two.txt' | ForEach-Object -Process { Join-Path -Path $folder -ChildPath $_ }
|
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $files
|
|
Set-Content -LiteralPath $files -Value 'File'
|
|
$emitted = 0
|
|
$caught = $null
|
|
$Error.Clear()
|
|
try {
|
|
Get-ChildItem2 -Path $folder -File -ErrorAction SilentlyContinue | ForEach-Object -Process {
|
|
$emitted++
|
|
throw [System.UnauthorizedAccessException]::new('Downstream failure')
|
|
}
|
|
}
|
|
catch {
|
|
$caught = $_
|
|
}
|
|
|
|
$caught.Exception | Should -BeOfType [System.UnauthorizedAccessException]
|
|
$caught.Exception.Message | Should -BeExactly 'Downstream failure'
|
|
$emitted | Should -Be 1
|
|
@($Error | Where-Object -FilterScript { $_.FullyQualifiedErrorId -like 'DirUnauthorizedAccessError,*' }) | Should -BeNullOrEmpty
|
|
}
|
|
|
|
It 'Remove-Item2 should pass on a thrown IOException and leave the next item' {
|
|
$pair = New-Pair
|
|
$emitted = 0
|
|
$caught = $null
|
|
$Error.Clear()
|
|
try {
|
|
Remove-Item2 -Path $pair.First, $pair.Second -PassThru -ErrorAction SilentlyContinue | ForEach-Object -Process {
|
|
$emitted++
|
|
throw [System.IO.IOException]::new('Downstream failure')
|
|
}
|
|
}
|
|
catch {
|
|
$caught = $_
|
|
}
|
|
|
|
$caught.Exception | Should -BeOfType [System.IO.IOException]
|
|
$caught.Exception.Message | Should -BeExactly 'Downstream failure'
|
|
$emitted | Should -Be 1
|
|
$pair.Second | Should -Exist
|
|
@($Error | Where-Object -FilterScript { $_.FullyQualifiedErrorId -like 'DeleteError,*' }) | Should -BeNullOrEmpty
|
|
}
|
|
}
|
|
|
|
# A cmdlet also meets the end of the pipeline where it did not write: another call of PowerShell can raise it too. The
|
|
# check that every catch-all makes recognizes the exceptions by their types. PowerShell keeps the exceptions of break and
|
|
# continue internal, so they are recognized by the name of their base type, which a stand-in with that name shows.
|
|
Describe 'Recognizing the end of a pipeline by the type of the exception' {
|
|
BeforeAll {
|
|
if (-not ('NtfsSecurityTests.StandInForBreak' -as [type])) {
|
|
# The compiler warns that the stand-in has the name of an imported type, and Add-Type treats a warning as an error.
|
|
Add-Type -IgnoreWarnings -TypeDefinition @'
|
|
namespace System.Management.Automation { public class FlowControlException : System.Exception { } }
|
|
namespace NtfsSecurityTests { public class StandInForBreak : System.Management.Automation.FlowControlException { } }
|
|
'@
|
|
}
|
|
|
|
$isEnd = [NTFSSecurity.BaseCmdlet].Assembly.GetType('NTFSSecurity.PipelineControl').GetMethod(
|
|
'IsEnd', [System.Reflection.BindingFlags] 'NonPublic, Static')
|
|
}
|
|
|
|
It 'Should recognize a PipelineStoppedException' {
|
|
$isEnd.Invoke($null, @([System.Management.Automation.PipelineStoppedException]::new())) | Should -BeTrue
|
|
}
|
|
|
|
It 'Should recognize an exception whose base type is the flow control exception of PowerShell' {
|
|
$isEnd.Invoke($null, @([NtfsSecurityTests.StandInForBreak]::new())) | Should -BeTrue
|
|
}
|
|
|
|
It 'Should not recognize <Description>' -ForEach @(
|
|
@{ Description = 'a failure of an item'; Exception = [System.InvalidOperationException]::new('Failure') }
|
|
@{ Description = 'an access denial'; Exception = [System.UnauthorizedAccessException]::new('Denied') }
|
|
@{ Description = 'a failure with an inner exception that ends the pipeline'; Exception = [System.InvalidOperationException]::new('Failure', [System.Management.Automation.PipelineStoppedException]::new()) }
|
|
) {
|
|
$isEnd.Invoke($null, @($Exception)) | Should -BeFalse
|
|
}
|
|
}
|
|
|