private/tests/Invoke-ZtTest.ps1
|
function Invoke-ZtTest { <# .SYNOPSIS Execute individual tests and collect execution statistics. .DESCRIPTION Execute individual tests and collect execution statistics. This command is expected to be run from background runspaces launched by Start-ZtTestExecution. Use Get-ZtTestStatistics to retrieve the results of these executions. .PARAMETER Test The test object to process. Expects an object as returned by Get-ZtTest. .PARAMETER Database The Database used for accessing cached tenant data. .PARAMETER LogsPath Path to the logs folder where per-test log files are written. If not specified, no log files are written. .PARAMETER TestTimeout Maximum time a single test is allowed to run before it is stopped. TimeSpan.Zero disables the timeout. .EXAMPLE PS C:\> Invoke-ZtTest -Test $_ -Database $global:database -LogsPath $logsPath Executes the current test with the globally cached database connection and writes a log file. .EXAMPLE PS C:\> Invoke-ZtTest -Test $_ -Database $global:database -LogsPath $logsPath -TestTimeout ([timespan]::FromMinutes(30)) Executes the test with a 30-minute timeout. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [PSTypeName('ZeroTrustAssessment.Test')] $Test, [DuckDB.NET.Data.DuckDBConnection] $Database, [string] $LogsPath, [timespan] $TestTimeout = [timespan]::Zero ) begin { $previousMessages = Get-PSFMessage -Runspace ([runspace]::DefaultRunspace.InstanceId) $result = [PSCustomObject]@{ PSTypeName = 'ZeroTrustAssessment.TestStatistics' TestID = $Test.TestID Test = $Test # Performance Metrics, in case we want to identify problematic tests Start = $null End = $null Duration = $null # What Happened? Success = $true Error = $null Messages = $null TimedOut = $false # Test should have no output, but we'll catch it anyways, just in case Output = $null } } process { Write-PSFMessage -Message "Processing test '{0}'" -StringValues $Test.TestID -Target $Test -Tag start # Check if the function exists and what parameters it has $command = Get-Command $Test.Command -ErrorAction SilentlyContinue if (-not $command) { Write-PSFMessage -Level Warning -Message "Test command for test '{0}' not found" -StringValues $Test.TestID -Target $Test throw "Test command for test '$($Test.TestID)' not found" } $dbParam = @{} if (($null -ne $command) -and $command.Parameters.ContainsKey("Database") -and $Database) { $dbParam.Database = $Database } # Write stub log file and progress entry so hanging tests are visible if ($LogsPath) { Write-ZtTestProgress -TestID $Test.TestID -LogsPath $LogsPath -Action Started try { $stubPath = Join-Path $LogsPath "$($Test.TestID).md" [System.IO.File]::WriteAllText($stubPath, "# Test: $($Test.TestID) - Started at $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff'))$([System.Environment]::NewLine)") } catch { Write-PSFMessage -Level Warning -Message "Failed to write stub test log for test '{0}': {1}" -StringValues $Test.TestID, $_ -Tag log } } $timeoutEnabled = $TestTimeout -gt [timespan]::Zero if ($timeoutEnabled) { Initialize-ZtTimeoutHelper } try { # Set Current Test for "Add-ZtTestResultDetail to pick up" $script:__ztCurrentTest = $Test $result.Start = Get-Date if (-not $timeoutEnabled) { # No timeout — run directly in the current thread (original behavior) $result.Output = & $command @dbParam -ErrorAction Stop } else { # Run test in a child PowerShell pipeline with a timer-based timeout. # Strategy: Use [powershell]::Create(CurrentRunspace) + synchronous Invoke(), # combined with a .NET timeout controller that calls ps.Stop() from a # ThreadPool thread when the timeout expires. We also track whether the # timer actually fired, so PipelineStoppedException is not blindly treated # as a timeout condition. $ps = $null $timeoutController = $null $timeoutTriggered = $false try { $ps = [powershell]::Create([System.Management.Automation.RunspaceMode]::CurrentRunspace) $null = $ps.AddCommand($command.Name) foreach ($key in $dbParam.Keys) { $null = $ps.AddParameter($key, $dbParam[$key]) } $null = $ps.AddParameter('ErrorAction', 'Stop') # Schedule a .NET timer to call ps.Stop() when the timeout expires $timeoutController = [ZeroTrustAssessment.TimeoutHelper]::CreateTimeoutController($ps, [int]$TestTimeout.TotalMilliseconds) $result.Output = $ps.Invoke() $timeoutTriggered = $timeoutController.Fired $timeoutController.Dispose() $timeoutController = $null # When Stop() is called on a CurrentRunspace pipeline, Invoke() may return # silently instead of throwing PipelineStoppedException. Detect this via # the timer-fired flag and the final pipeline state. if ($timeoutTriggered -or $ps.InvocationStateInfo.State -eq [System.Management.Automation.PSInvocationState]::Stopped) { Set-ZtTimedOutResult -Result $result -Test $Test -Timeout $TestTimeout } elseif ($ps.HadErrors) { # Surface any non-terminating errors from the child pipeline $firstError = $ps.Streams.Error | Select-Object -First 1 if ($firstError) { throw $firstError.Exception } } } catch [System.Management.Automation.PipelineStoppedException] { # PipelineStoppedException may be raised by the timeout controller or by # unrelated stop conditions. Only classify it as timeout if the controller # actually fired or the pipeline ended in Stopped state. $timeoutTriggered = ($null -ne $timeoutController -and $timeoutController.Fired) -or $timeoutTriggered if ($timeoutTriggered -or ($null -ne $ps -and $ps.InvocationStateInfo.State -eq [System.Management.Automation.PSInvocationState]::Stopped)) { Set-ZtTimedOutResult -Result $result -Test $Test -Timeout $TestTimeout } else { throw } } finally { if ($null -ne $timeoutController) { $timeoutController.Dispose() } if ($null -ne $ps) { $ps.Dispose() } } } } catch { Write-PSFMessage -Level Warning -Message "Error executing test '{0}'" -StringValues $Test.TestID -Target $Test -ErrorRecord $_ $result.Success = $false $result.Error = $_ } finally { $result.End = Get-Date $result.Duration = $result.End - $result.Start # Reset marker in an assured way, to prevent confusion about the current test being executed $script:__ztCurrentTest = $null } Write-PSFMessage -Message "Processing test '{0}' - Concluded" -StringValues $Test.TestID -Target $Test -Tag end } end { $result.Messages = Get-PSFMessage -Runspace ([runspace]::DefaultRunspace.InstanceId) | Where-Object { $_ -notin $previousMessages } Write-ZtTestStatistics -Result $result # Write per-test log file (overwrites stub) and progress entry if ($LogsPath) { Write-ZtTestLog -Result $result -LogsPath $LogsPath if ($result.TimedOut) { Write-ZtTestProgress -TestID $result.TestID -LogsPath $LogsPath -Action TimedOut -Duration $result.Duration -ErrorMessage "$($result.Error)" } elseif ($result.Success) { Write-ZtTestProgress -TestID $result.TestID -LogsPath $LogsPath -Action Completed -Duration $result.Duration } else { $progressError = if ($result.Error) { "$($result.Error)" } else { $null } Write-ZtTestProgress -TestID $result.TestID -LogsPath $LogsPath -Action Failed -Duration $result.Duration -ErrorMessage $progressError } } $result } } |