helpers/test/Invoke-WUGScheduledTaskE2ETest.ps1
|
<#
.SYNOPSIS End-to-end test for Discovery Scheduled Tasks with System DPAPI Vault. .DESCRIPTION This script validates the complete scheduled task flow for discovery helpers: 1. System Vault Setup - Creates/populates the LocalMachine DPAPI vault 2. Task Registration - Registers a scheduled task running as SYSTEM 3. Task Execution - Runs the task and waits for completion 4. Output Verification - Checks for expected output files and log content 5. Cleanup - Optionally removes the test task and artifacts The test uses WindowsAttributes discovery against localhost by default since it requires no external infrastructure. Other providers can be tested by specifying credentials when prompted. DPAPI LocalMachine Vault: Credentials are encrypted using the machine key (not user key), allowing SYSTEM and any administrator to decrypt them. This enables scheduled tasks to run without storing plaintext passwords or requiring user logon. Output Artifacts: - Dashboard HTML : %ProgramData%\WhatsUpGoldPS\Output\<Provider>-Dashboard.html - Log files : %ProgramData%\WhatsUpGoldPS\Output\logs\<TaskName>_*.log - JSON export : %ProgramData%\WhatsUpGoldPS\Output\<Provider>-DiscoveryPlan.json .PARAMETER Provider Discovery provider to test. Default: WindowsAttributes (targets localhost). Options: AWS, Azure, Proxmox, CiscoWLC, CUCM, Docker, WindowsAttributes, WindowsDiskIO. .PARAMETER Target Target host(s) for discovery. Default varies by provider. - WindowsAttributes/WindowsDiskIO: localhost - Docker: localhost (requires Docker Engine API on port 2375) - Others: requires explicit target .PARAMETER Action Discovery action to test. Default: Dashboard. Options: Dashboard, ExportJSON, ExportCSV, ShowTable, PushToWUG, DashboardAndPush. .PARAMETER SkipVaultSetup Skip the interactive vault credential setup. Use when vault is already populated (e.g., re-running test after initial setup). .PARAMETER SkipTaskRegistration Skip task registration. Use when task already exists and you only want to run it again. .PARAMETER RunAsCurrentUser Run the task as the current user instead of SYSTEM. This uses the CurrentUser DPAPI vault instead of LocalMachine. Useful for debugging but does not test the SYSTEM vault flow. .PARAMETER WaitTimeoutSeconds How long to wait for the scheduled task to complete. Default: 300 (5 min). .PARAMETER NoCleanup Do not remove the scheduled task after the test. Useful for debugging or when you want to keep the task for future runs. .PARAMETER OutputPath Base directory for output files. Default: %ProgramData%\WhatsUpGoldPS\Output. When running as SYSTEM, this MUST be accessible by SYSTEM (not user profile). .PARAMETER OpenDashboard Open the generated dashboard in the default browser after successful test. .PARAMETER OpenLog Open the task execution log in notepad after the test. .PARAMETER Verbose Enable verbose output showing detailed test progress. .EXAMPLE .\Invoke-WUGScheduledTaskE2ETest.ps1 # Full E2E test with WindowsAttributes against localhost. # Prompts for WMI credentials, registers SYSTEM task, runs it, verifies output. .EXAMPLE .\Invoke-WUGScheduledTaskE2ETest.ps1 -Provider Proxmox -Target '192.168.1.30' # Test Proxmox discovery. Prompts for API token/credentials. .EXAMPLE .\Invoke-WUGScheduledTaskE2ETest.ps1 -SkipVaultSetup -SkipTaskRegistration -OpenLog # Re-run existing task and view the log (useful for debugging). .EXAMPLE .\Invoke-WUGScheduledTaskE2ETest.ps1 -RunAsCurrentUser -NoCleanup # Test as current user (not SYSTEM) and keep the task. .NOTES Author : jason@wug.ninja Created : 2026-07-06 Requires: PowerShell 5.1+, Administrator rights IMPORTANT: Run this script from an elevated PowerShell prompt. Scheduled task registration and LocalMachine DPAPI require admin rights. #> [CmdletBinding()] param( [ValidateSet('AWS', 'Azure', 'Proxmox', 'CiscoWLC', 'CUCM', 'Docker', 'WindowsAttributes', 'WindowsDiskIO', 'HyperV', 'VMware', 'F5', 'Fortinet', 'Bigleaf', 'GCP', 'OCI', 'Nutanix')] [string]$Provider = 'WindowsAttributes', [string[]]$Target, [ValidateSet('Dashboard', 'ExportJSON', 'ExportCSV', 'ShowTable', 'PushToWUG', 'DashboardAndPush', 'None')] [string]$Action = 'Dashboard', [switch]$SkipVaultSetup, [switch]$SkipTaskRegistration, [switch]$RunAsCurrentUser, [int]$WaitTimeoutSeconds = 300, [switch]$NoCleanup, [string]$OutputPath, [switch]$OpenDashboard, [switch]$OpenLog ) # ============================================================================ # region Setup and Validation # ============================================================================ $ErrorActionPreference = 'Stop' $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent $discoveryDir = Join-Path (Split-Path $scriptDir -Parent) 'discovery' $timestamp = (Get-Date).ToString('yyyyMMdd_HHmmss') # Verify running as admin $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { Write-Error "This script requires administrator privileges. Please run from an elevated PowerShell prompt." return } # Set default targets by provider if (-not $Target) { switch ($Provider) { 'WindowsAttributes' { $Target = @('localhost') } 'WindowsDiskIO' { $Target = @('localhost') } 'Docker' { $Target = @('localhost') } default { Write-Error "Provider '$Provider' requires -Target parameter. Please specify the target host(s)." return } } } # Provider-specific parameter mapping # Some providers use different parameter names for their target # IsArray indicates whether the target parameter accepts multiple values $script:ProviderParamMap = @{ Azure = @{ TargetParam = 'TenantId'; TargetDesc = 'Azure Tenant ID'; IsArray = $false } AWS = @{ TargetParam = 'Region'; TargetDesc = 'AWS Region(s)'; IsArray = $true } GCP = @{ TargetParam = 'ProjectId'; TargetDesc = 'GCP Project ID(s)'; IsArray = $true } OCI = @{ TargetParam = 'TenancyId'; TargetDesc = 'OCI Tenancy OCID'; IsArray = $false } Proxmox = @{ TargetParam = 'Target'; TargetDesc = 'Proxmox Host(s)'; IsArray = $true } HyperV = @{ TargetParam = 'Target'; TargetDesc = 'Hyper-V Host(s)'; IsArray = $true } VMware = @{ TargetParam = 'Target'; TargetDesc = 'vCenter/ESXi Host'; IsArray = $false } Docker = @{ TargetParam = 'Target'; TargetDesc = 'Docker Host(s)'; IsArray = $true } F5 = @{ TargetParam = 'Target'; TargetDesc = 'F5 BIG-IP Host(s)'; IsArray = $true } Fortinet = @{ TargetParam = 'Target'; TargetDesc = 'FortiGate Host(s)'; IsArray = $true } Nutanix = @{ TargetParam = 'Target'; TargetDesc = 'Nutanix Prism Host(s)'; IsArray = $true } CiscoWLC = @{ TargetParam = 'Target'; TargetDesc = 'Cisco WLC Host(s)'; IsArray = $true } CUCM = @{ TargetParam = 'Target'; TargetDesc = 'CUCM Publisher Host'; IsArray = $true } Bigleaf = @{ TargetParam = 'Target'; TargetDesc = 'Bigleaf Site Label'; IsArray = $false } WindowsAttributes = @{ TargetParam = 'Target'; TargetDesc = 'Windows Host(s)'; IsArray = $true } WindowsDiskIO = @{ TargetParam = 'Target'; TargetDesc = 'Windows Host(s)'; IsArray = $true } } # Get provider-specific parameter info $providerConfig = $script:ProviderParamMap[$Provider] if (-not $providerConfig) { $providerConfig = @{ TargetParam = 'Target'; TargetDesc = 'Target Host(s)' } } # Set output path - MUST be SYSTEM-accessible when using LocalMachine vault if (-not $OutputPath) { if ($RunAsCurrentUser) { $OutputPath = Join-Path $env:LOCALAPPDATA 'WhatsUpGoldPS\DiscoveryHelpers\Output' } else { $OutputPath = Join-Path $env:ProgramData 'WhatsUpGoldPS\Output' } } if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $logDir = Join-Path $OutputPath 'logs' if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } # Task naming $taskName = "E2ETest-$Provider-$timestamp" $taskFolder = '\WhatsUpGoldPS' # Load discovery helpers $helpersPath = Join-Path $discoveryDir 'DiscoveryHelpers.ps1' if (-not (Test-Path $helpersPath)) { Write-Error "DiscoveryHelpers.ps1 not found at '$helpersPath'. Cannot continue." return } . $helpersPath Write-Host '' Write-Host ' ╔═══════════════════════════════════════════════════════════════════╗' -ForegroundColor Cyan Write-Host ' ║ Scheduled Task E2E Test - System DPAPI Vault ║' -ForegroundColor Cyan Write-Host ' ╠═══════════════════════════════════════════════════════════════════╣' -ForegroundColor Cyan Write-Host " ║ Provider : $($Provider.PadRight(52))║" -ForegroundColor White Write-Host " ║ Target : $((($Target -join ', ').PadRight(52)))║" -ForegroundColor White Write-Host " ║ Action : $($Action.PadRight(52))║" -ForegroundColor White Write-Host " ║ Task Name : $($taskName.PadRight(52))║" -ForegroundColor White Write-Host " ║ Output : $(($OutputPath.Substring(0, [Math]::Min($OutputPath.Length, 52))).PadRight(52))║" -ForegroundColor White Write-Host " ║ Run As : $(if ($RunAsCurrentUser) { 'Current User (DPAPI CurrentUser)' } else { 'SYSTEM (DPAPI LocalMachine)' })$((' ' * (52 - $(if ($RunAsCurrentUser) { 32 } else { 28 }))))║" -ForegroundColor $(if ($RunAsCurrentUser) { 'Yellow' } else { 'Green' }) Write-Host ' ╚═══════════════════════════════════════════════════════════════════╝' -ForegroundColor Cyan Write-Host '' # endregion # ============================================================================ # region Test Results Tracking # ============================================================================ $script:TestResults = [System.Collections.Generic.List[PSCustomObject]]::new() $script:TestStartTime = Get-Date function Record-TestStep { param( [string]$Step, [string]$Status, # Pass, Fail, Skip, Info [string]$Detail = '', [int]$DurationMs = 0 ) $color = switch ($Status) { 'Pass' { 'Green' } 'Fail' { 'Red' } 'Skip' { 'Yellow' } 'Info' { 'Cyan' } default { 'White' } } $icon = switch ($Status) { 'Pass' { '[OK]' } 'Fail' { '[X]' } 'Skip' { '[~]' } 'Info' { '[i]' } default { '[ ]' } } Write-Host " $icon " -ForegroundColor $color -NoNewline Write-Host "$Step" -ForegroundColor White -NoNewline if ($Detail) { Write-Host " - $Detail" -ForegroundColor DarkGray } else { Write-Host '' } $script:TestResults.Add([PSCustomObject]@{ Step = $Step Status = $Status Detail = $Detail DurationMs = $DurationMs Timestamp = (Get-Date).ToString('HH:mm:ss') }) } function Show-TestSummary { $passed = @($script:TestResults | Where-Object Status -eq 'Pass').Count $failed = @($script:TestResults | Where-Object Status -eq 'Fail').Count $skipped = @($script:TestResults | Where-Object Status -eq 'Skip').Count $totalMs = ((Get-Date) - $script:TestStartTime).TotalMilliseconds Write-Host '' Write-Host ' ═══════════════════════════════════════════════════════════════════' -ForegroundColor DarkCyan Write-Host ' Test Summary' -ForegroundColor Cyan Write-Host ' ═══════════════════════════════════════════════════════════════════' -ForegroundColor DarkCyan Write-Host " Passed : $passed" -ForegroundColor Green Write-Host " Failed : $failed" -ForegroundColor $(if ($failed -gt 0) { 'Red' } else { 'DarkGray' }) Write-Host " Skipped : $skipped" -ForegroundColor $(if ($skipped -gt 0) { 'Yellow' } else { 'DarkGray' }) Write-Host " Duration: $([Math]::Round($totalMs / 1000, 1))s" -ForegroundColor White Write-Host ' ═══════════════════════════════════════════════════════════════════' -ForegroundColor DarkCyan if ($failed -eq 0) { Write-Host ' ALL TESTS PASSED' -ForegroundColor Green } else { Write-Host ' SOME TESTS FAILED' -ForegroundColor Red $script:TestResults | Where-Object Status -eq 'Fail' | ForEach-Object { Write-Host " - $($_.Step): $($_.Detail)" -ForegroundColor Red } } Write-Host '' return ($failed -eq 0) } # endregion # ============================================================================ # region Step 1: System Vault Setup # ============================================================================ Write-Host '' Write-Host ' Step 1: Vault Setup' -ForegroundColor Cyan Write-Host ' -------------------' -ForegroundColor DarkCyan if ($SkipVaultSetup) { Record-TestStep -Step 'Vault Setup' -Status 'Skip' -Detail 'Skipped via -SkipVaultSetup' } else { $vaultStepStart = Get-Date try { if ($RunAsCurrentUser) { # Use CurrentUser vault (default) Write-Host ' Using CurrentUser DPAPI vault (task will run as current user)' -ForegroundColor Yellow Record-TestStep -Step 'Vault Scope' -Status 'Info' -Detail 'CurrentUser (user profile)' } else { # Switch to LocalMachine vault for SYSTEM access Set-DiscoveryVaultScope -Scope LocalMachine Record-TestStep -Step 'Vault Scope' -Status 'Pass' -Detail 'LocalMachine (SYSTEM accessible)' } # Verify vault directory exists and is accessible $vaultPath = $script:DiscoveryVaultPath if (-not (Test-Path $vaultPath)) { Initialize-DiscoveryVault } Record-TestStep -Step 'Vault Directory' -Status 'Pass' -Detail $vaultPath # Now run the provider setup to populate credentials $setupScript = Join-Path $discoveryDir "Setup-$Provider-Discovery.ps1" if (-not (Test-Path $setupScript)) { throw "Setup script not found: $setupScript" } Write-Host '' Write-Host ' Running provider setup to populate vault credentials...' -ForegroundColor Yellow Write-Host ' (Answer the prompts to store credentials in the vault)' -ForegroundColor DarkGray Write-Host '' # Run setup with Action=None to just populate the vault without doing discovery # Handle single-value vs array parameters $targetValue = if ($providerConfig.IsArray) { $Target } else { $Target[0] } $setupArgs = @{ $providerConfig.TargetParam = $targetValue Action = 'None' OutputPath = $OutputPath } & $setupScript @setupArgs $vaultDurationMs = [int]((Get-Date) - $vaultStepStart).TotalMilliseconds Record-TestStep -Step 'Vault Credential Setup' -Status 'Pass' -Detail "Credentials saved to vault" -DurationMs $vaultDurationMs } catch { Record-TestStep -Step 'Vault Setup' -Status 'Fail' -Detail $_.Exception.Message Write-Error "Vault setup failed: $_" Show-TestSummary return } } # endregion # ============================================================================ # region Step 2: Create Wrapper CMD File # ============================================================================ Write-Host '' Write-Host ' Step 2: Create Wrapper Script' -ForegroundColor Cyan Write-Host ' ------------------------------' -ForegroundColor DarkCyan $wrapperStepStart = Get-Date # Build provider-specific argument string for wrapper scripts $targetParamName = $providerConfig.TargetParam # Build target value string for display and for wrapper script if ($providerConfig.IsArray) { $targetQuoted = ($Target | ForEach-Object { "'$_'" }) -join ',' $targetDisplayValue = $Target -join ', ' } else { $targetQuoted = "'$($Target[0])'" $targetDisplayValue = $Target[0] } $providerArgString = "-$targetParamName $targetQuoted -Action $Action -NonInteractive -OutputPath '$OutputPath'" # Create a .cmd wrapper that handles directory switching and environment setup # This pattern makes debugging easier - you can run the .cmd manually to test $wrapperDir = Join-Path $OutputPath 'wrappers' if (-not (Test-Path $wrapperDir)) { New-Item -ItemType Directory -Path $wrapperDir -Force | Out-Null } $wrapperCmdPath = Join-Path $wrapperDir "$taskName.cmd" $wrapperPs1Path = Join-Path $wrapperDir "$taskName.ps1" # The CMD wrapper sets up the environment and calls PowerShell $cmdContent = @" @echo off REM ============================================================================ REM Discovery Scheduled Task Wrapper - $taskName REM Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') REM Provider : $Provider REM $($providerConfig.TargetDesc) : $($Target -join ', ') REM ============================================================================ REM Set working directory to discovery folder cd /d "$discoveryDir" REM Set vault scope environment variable $(if (-not $RunAsCurrentUser) { 'set WUG_VAULT_SCOPE=LocalMachine' } else { 'REM Using CurrentUser vault' }) REM Execute the PowerShell wrapper script powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$wrapperPs1Path" REM Capture exit code set EXITCODE=%ERRORLEVEL% REM Log completion echo. echo Exit code: %EXITCODE% echo Completed: %DATE% %TIME% exit /b %EXITCODE% "@ # The PowerShell wrapper does the actual work with full logging $ps1Content = @" #Requires -Version 5.1 <# .SYNOPSIS PowerShell wrapper for scheduled discovery task: $taskName Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') #> `$ErrorActionPreference = 'Continue' `$VerbosePreference = 'Continue' # ============================================================================ # Logging Setup # ============================================================================ `$logDir = '$logDir' `$logFile = Join-Path `$logDir ('$taskName' + '_' + (Get-Date -Format 'yyyyMMdd_HHmmss') + '.log') if (-not (Test-Path `$logDir)) { try { New-Item -ItemType Directory -Path `$logDir -Force | Out-Null } catch {} } `$transcriptStarted = `$false try { Start-Transcript -Path `$logFile -Force | Out-Null `$transcriptStarted = `$true } catch { # Fallback: write initial info to log file "[`$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Start-Transcript FAILED: `$_" | Out-File -FilePath `$logFile -Encoding UTF8 -Force } # ============================================================================ # Task Context Information # ============================================================================ Write-Host '' Write-Host '═══════════════════════════════════════════════════════════════════════' Write-Host ' Discovery Scheduled Task Execution' Write-Host '═══════════════════════════════════════════════════════════════════════' Write-Host "Task Name : $taskName" Write-Host "Provider : $Provider" Write-Host "Target : $($Target -join ', ')" Write-Host "Action : $Action" Write-Host "DateTime : `$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Write-Host "User : `$env:USERDOMAIN\`$env:USERNAME" Write-Host "Machine : `$env:COMPUTERNAME" Write-Host "PID : `$PID" Write-Host "WorkDir : `$(Get-Location)" Write-Host "Vault Scope : `$env:WUG_VAULT_SCOPE" Write-Host "Log File : `$logFile" Write-Host '═══════════════════════════════════════════════════════════════════════' Write-Host '' # ============================================================================ # Set Vault Scope # ============================================================================ $(if (-not $RunAsCurrentUser) { "`$env:WUG_VAULT_SCOPE = 'LocalMachine'" } else { "# Using CurrentUser vault (default)" }) # ============================================================================ # Load Discovery Helpers # ============================================================================ `$discoveryDir = '$discoveryDir' `$helpersPath = Join-Path `$discoveryDir 'DiscoveryHelpers.ps1' Write-Host "Loading DiscoveryHelpers from: `$helpersPath" if (-not (Test-Path `$helpersPath)) { Write-Error "DiscoveryHelpers.ps1 not found at '`$helpersPath'" exit 1 } . `$helpersPath # ============================================================================ # Execute Discovery # ============================================================================ `$exitCode = 0 `$setupScript = Join-Path `$discoveryDir 'Setup-$Provider-Discovery.ps1' # Target value(s) for this provider $(if ($providerConfig.IsArray) { "`$targetValue = @($targetQuoted)" } else { "`$targetValue = $targetQuoted" }) # Build splat for provider-specific parameters `$runArgs = @{ '$targetParamName' = `$targetValue 'Action' = '$Action' 'NonInteractive' = `$true 'OutputPath' = '$OutputPath' } Write-Host '' Write-Host "Executing: `$setupScript" Write-Host "Arguments:" `$runArgs.GetEnumerator() | ForEach-Object { Write-Host " -`$(`$_.Key) `$(`$_.Value)" } Write-Host '' try { & `$setupScript @runArgs if (`$LASTEXITCODE) { `$exitCode = `$LASTEXITCODE } Write-Host '' Write-Host 'Discovery script completed.' } catch { Write-Host '' Write-Host "FATAL ERROR: `$_" -ForegroundColor Red Write-Host `$_.ScriptStackTrace `$exitCode = 1 } # ============================================================================ # Completion # ============================================================================ Write-Host '' Write-Host '═══════════════════════════════════════════════════════════════════════' Write-Host "Exit Code : `$exitCode" Write-Host "Completed : `$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Write-Host '═══════════════════════════════════════════════════════════════════════' if (`$transcriptStarted) { try { Stop-Transcript } catch {} } exit `$exitCode "@ try { # Write wrapper files $cmdContent | Out-File -FilePath $wrapperCmdPath -Encoding ASCII -Force $ps1Content | Out-File -FilePath $wrapperPs1Path -Encoding UTF8 -Force $wrapperDurationMs = [int]((Get-Date) - $wrapperStepStart).TotalMilliseconds Record-TestStep -Step 'Create CMD Wrapper' -Status 'Pass' -Detail $wrapperCmdPath -DurationMs $wrapperDurationMs Record-TestStep -Step 'Create PS1 Wrapper' -Status 'Pass' -Detail $wrapperPs1Path Write-Host '' Write-Host ' Wrapper files created:' -ForegroundColor DarkGray Write-Host " CMD: $wrapperCmdPath" -ForegroundColor Gray Write-Host " PS1: $wrapperPs1Path" -ForegroundColor Gray Write-Host '' Write-Host ' To debug manually, run:' -ForegroundColor DarkGray Write-Host " $wrapperCmdPath" -ForegroundColor Gray Write-Host '' } catch { Record-TestStep -Step 'Create Wrapper Scripts' -Status 'Fail' -Detail $_.Exception.Message Write-Error "Failed to create wrapper scripts: $_" Show-TestSummary return } # endregion # ============================================================================ # region Step 3: Register Scheduled Task # ============================================================================ Write-Host '' Write-Host ' Step 3: Register Scheduled Task' -ForegroundColor Cyan Write-Host ' --------------------------------' -ForegroundColor DarkCyan if ($SkipTaskRegistration) { Record-TestStep -Step 'Task Registration' -Status 'Skip' -Detail 'Skipped via -SkipTaskRegistration' } else { $regStepStart = Get-Date try { # Build the encoded command that runs our wrapper $fullCommand = @" `$ErrorActionPreference = 'Continue' Set-Location '$discoveryDir' $(if (-not $RunAsCurrentUser) { "`$env:WUG_VAULT_SCOPE = 'LocalMachine'" }) & '$wrapperPs1Path' exit `$LASTEXITCODE "@ $encodedBytes = [System.Text.Encoding]::Unicode.GetBytes($fullCommand) $encodedCommand = [Convert]::ToBase64String($encodedBytes) $taskArgs = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedCommand" # Create task action $taskAction = New-ScheduledTaskAction ` -Execute 'powershell.exe' ` -Argument $taskArgs ` -WorkingDirectory $discoveryDir # Create trigger (Once, now + 1 minute for testing) $triggerTime = (Get-Date).AddMinutes(1) $taskTrigger = New-ScheduledTaskTrigger -Once -At $triggerTime # Create principal (SYSTEM or current user) if ($RunAsCurrentUser) { $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name $taskPrincipal = New-ScheduledTaskPrincipal ` -UserId $currentUser ` -LogonType S4U ` -RunLevel Limited } else { $taskPrincipal = New-ScheduledTaskPrincipal ` -UserId 'SYSTEM' ` -LogonType ServiceAccount ` -RunLevel Highest } # Create settings $taskSettings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -ExecutionTimeLimit (New-TimeSpan -Minutes 30) ` -MultipleInstances IgnoreNew # Create the task folder if it doesn't exist $scheduler = New-Object -ComObject Schedule.Service $scheduler.Connect() try { $scheduler.GetFolder($taskFolder) | Out-Null } catch { $rootFolder = $scheduler.GetFolder('\') $rootFolder.CreateFolder($taskFolder) | Out-Null Write-Verbose "Created task folder: $taskFolder" } # Register the task Register-ScheduledTask ` -TaskName $taskName ` -TaskPath $taskFolder ` -Action $taskAction ` -Trigger $taskTrigger ` -Principal $taskPrincipal ` -Settings $taskSettings ` -Force | Out-Null $regDurationMs = [int]((Get-Date) - $regStepStart).TotalMilliseconds Record-TestStep -Step 'Task Registration' -Status 'Pass' -Detail "Registered as $taskFolder\$taskName" -DurationMs $regDurationMs Write-Host '' Write-Host " Task registered: $taskFolder\$taskName" -ForegroundColor Green Write-Host " Run as: $(if ($RunAsCurrentUser) { $currentUser } else { 'SYSTEM' })" -ForegroundColor White Write-Host " Trigger: $($triggerTime.ToString('HH:mm:ss'))" -ForegroundColor White Write-Host '' } catch { Record-TestStep -Step 'Task Registration' -Status 'Fail' -Detail $_.Exception.Message Write-Error "Task registration failed: $_" Show-TestSummary return } } # endregion # ============================================================================ # region Step 4: Execute Task and Wait # ============================================================================ Write-Host '' Write-Host ' Step 4: Execute Task' -ForegroundColor Cyan Write-Host ' --------------------' -ForegroundColor DarkCyan $execStepStart = Get-Date try { # Start the task immediately (don't wait for trigger) Write-Host ' Starting task...' -ForegroundColor Yellow Start-ScheduledTask -TaskName $taskName -TaskPath "$taskFolder\" Record-TestStep -Step 'Task Start' -Status 'Pass' -Detail 'Task started' # Wait for completion with timeout Write-Host " Waiting for task completion (timeout: ${WaitTimeoutSeconds}s)..." -ForegroundColor Yellow $waitStart = Get-Date $completed = $false $lastState = '' while (-not $completed -and ((Get-Date) - $waitStart).TotalSeconds -lt $WaitTimeoutSeconds) { Start-Sleep -Seconds 2 $taskInfo = Get-ScheduledTask -TaskName $taskName -TaskPath "$taskFolder\" -ErrorAction SilentlyContinue if ($taskInfo.State -ne $lastState) { Write-Host " State: $($taskInfo.State)" -ForegroundColor DarkGray $lastState = $taskInfo.State } if ($taskInfo.State -eq 'Ready') { $completed = $true } } $execDurationMs = [int]((Get-Date) - $execStepStart).TotalMilliseconds if ($completed) { # Get the last run result $taskRunInfo = Get-ScheduledTaskInfo -TaskName $taskName -TaskPath "$taskFolder\" $lastRunResult = $taskRunInfo.LastRunTime $lastTaskResult = $taskRunInfo.LastTaskResult if ($lastTaskResult -eq 0) { Record-TestStep -Step 'Task Execution' -Status 'Pass' -Detail "Completed at $($lastRunResult.ToString('HH:mm:ss'))" -DurationMs $execDurationMs } else { Record-TestStep -Step 'Task Execution' -Status 'Fail' -Detail "Exit code: $lastTaskResult" -DurationMs $execDurationMs } } else { Record-TestStep -Step 'Task Execution' -Status 'Fail' -Detail "Timeout after ${WaitTimeoutSeconds}s" -DurationMs $execDurationMs } } catch { Record-TestStep -Step 'Task Execution' -Status 'Fail' -Detail $_.Exception.Message } # endregion # ============================================================================ # region Step 5: Verify Output # ============================================================================ Write-Host '' Write-Host ' Step 5: Verify Output' -ForegroundColor Cyan Write-Host ' ---------------------' -ForegroundColor DarkCyan $verifyStepStart = Get-Date # Check for log file $logFiles = @(Get-ChildItem -Path $logDir -Filter "$taskName*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending) if ($logFiles.Count -gt 0) { $latestLog = $logFiles[0] Record-TestStep -Step 'Log File Created' -Status 'Pass' -Detail $latestLog.Name # Check log content for errors $logContent = Get-Content $latestLog.FullName -Raw -ErrorAction SilentlyContinue if ($logContent -match 'FATAL|ERROR|Exception|failed') { Record-TestStep -Step 'Log Content Check' -Status 'Fail' -Detail 'Errors found in log' Write-Host '' Write-Host ' Log errors detected:' -ForegroundColor Red $logContent -split "`n" | Where-Object { $_ -match 'FATAL|ERROR|Exception|failed' } | ForEach-Object { Write-Host " $_" -ForegroundColor Red } } elseif ($logContent -match 'completed|success|Exit Code\s*:\s*0') { Record-TestStep -Step 'Log Content Check' -Status 'Pass' -Detail 'No errors, success indicators found' } else { Record-TestStep -Step 'Log Content Check' -Status 'Info' -Detail 'No clear success/failure indicators' } } else { Record-TestStep -Step 'Log File Created' -Status 'Fail' -Detail 'No log file found' } # Check for output files based on action $outputFiles = @() switch ($Action) { 'Dashboard' { # Search recursively — CiscoWLC puts dashboards in OutputPath\summary\dashboards\ $dashboardFiles = @(Get-ChildItem -Path $OutputPath -Recurse -Include '*Dashboard*.html','wireless-dashboard-*.html' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt $script:TestStartTime } | Sort-Object LastWriteTime -Descending) if ($dashboardFiles.Count -gt 0) { $outputFiles += $dashboardFiles[0] Record-TestStep -Step 'Dashboard Output' -Status 'Pass' -Detail $dashboardFiles[0].Name } else { # Check if dashboard exists but predates test start (timing issue) $anyDash = @(Get-ChildItem -Path $OutputPath -Recurse -Include '*Dashboard*.html','wireless-dashboard-*.html' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending) $detail = if ($anyDash.Count -gt 0) { "Dashboard exists but predates test start: $($anyDash[0].Name) ($($anyDash[0].LastWriteTime.ToString('HH:mm:ss')))" } else { 'No dashboard HTML found in output path or subdirectories' } Record-TestStep -Step 'Dashboard Output' -Status 'Fail' -Detail $detail } } 'ExportJSON' { $jsonFiles = @(Get-ChildItem -Path $OutputPath -Filter '*.json' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt $script:TestStartTime } | Sort-Object LastWriteTime -Descending) if ($jsonFiles.Count -gt 0) { $outputFiles += $jsonFiles[0] Record-TestStep -Step 'JSON Output' -Status 'Pass' -Detail $jsonFiles[0].Name } else { Record-TestStep -Step 'JSON Output' -Status 'Fail' -Detail 'No JSON file found' } } 'ExportCSV' { $csvFiles = @(Get-ChildItem -Path $OutputPath -Filter '*.csv' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt $script:TestStartTime } | Sort-Object LastWriteTime -Descending) if ($csvFiles.Count -gt 0) { $outputFiles += $csvFiles[0] Record-TestStep -Step 'CSV Output' -Status 'Pass' -Detail $csvFiles[0].Name } else { Record-TestStep -Step 'CSV Output' -Status 'Fail' -Detail 'No CSV file found' } } default { Record-TestStep -Step 'Output Files' -Status 'Info' -Detail "Action '$Action' may not produce output files" } } $verifyDurationMs = [int]((Get-Date) - $verifyStepStart).TotalMilliseconds # endregion # ============================================================================ # region Step 6: Cleanup (optional) # ============================================================================ Write-Host '' Write-Host ' Step 6: Cleanup' -ForegroundColor Cyan Write-Host ' ---------------' -ForegroundColor DarkCyan if ($NoCleanup) { Record-TestStep -Step 'Cleanup' -Status 'Skip' -Detail 'Skipped via -NoCleanup' Write-Host '' Write-Host ' Task retained for future runs:' -ForegroundColor Yellow Write-Host " Start-ScheduledTask -TaskName '$taskName' -TaskPath '$taskFolder\'" -ForegroundColor Gray Write-Host " Get-ScheduledTaskInfo -TaskName '$taskName' -TaskPath '$taskFolder\'" -ForegroundColor Gray Write-Host '' } else { try { Unregister-ScheduledTask -TaskName $taskName -TaskPath "$taskFolder\" -Confirm:$false -ErrorAction Stop Record-TestStep -Step 'Task Removal' -Status 'Pass' -Detail 'Task unregistered' } catch { Record-TestStep -Step 'Task Removal' -Status 'Fail' -Detail $_.Exception.Message } } # endregion # ============================================================================ # region Results and Open Files # ============================================================================ $allPassed = Show-TestSummary # Open dashboard if requested if ($OpenDashboard -and $outputFiles.Count -gt 0) { $dashFile = $outputFiles | Where-Object { $_.Name -match '\.html$' } | Select-Object -First 1 if ($dashFile) { Write-Host " Opening dashboard: $($dashFile.FullName)" -ForegroundColor Cyan Start-Process $dashFile.FullName } } # Open log if requested if ($OpenLog -and $logFiles.Count -gt 0) { Write-Host " Opening log: $($logFiles[0].FullName)" -ForegroundColor Cyan Start-Process 'notepad.exe' -ArgumentList $logFiles[0].FullName } # Output file locations for easy access Write-Host '' Write-Host ' Output Locations:' -ForegroundColor Cyan Write-Host " Logs : $logDir" -ForegroundColor White Write-Host " Output : $OutputPath" -ForegroundColor White Write-Host " Wrappers : $wrapperDir" -ForegroundColor White Write-Host '' if ($logFiles.Count -gt 0) { Write-Host ' View latest log:' -ForegroundColor DarkGray Write-Host " Get-Content '$($logFiles[0].FullName)' | more" -ForegroundColor Gray Write-Host '' } # Return success/failure return $allPassed # endregion # SIG # Begin signature block # MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDOUJIU5sJlvoJl # sgnxaHUrqtnXyLSN/tBQ2O7SCjsEhaCCJQ0wggVvMIIEV6ADAgECAhBI/JO0YFWU # jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI # DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM # EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy # dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG # EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv # IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA # A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s # hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD # J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7 # P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme # me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz # T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q # RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz # mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc # QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T # OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/ # AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID # AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD # VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV # HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE # VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v # ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE # KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI # hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF # OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC # J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ # pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl # d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH # +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggWNMIIEdaADAgECAhAOmxiO # +dAt5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYD # VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAi # BgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAw # MDBaFw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp # Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERp # Z2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC # AgoCggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsb # hA3EMB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iT # cMKyunWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGb # NOsFxl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclP # XuU15zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCr # VYJBMtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFP # ObURWBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTv # kpI6nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWM # cCxBYKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls # 5Q5SUUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBR # a2+xq4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6 # MIIBNjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qY # rhwPTzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8E # BAMCAYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k # aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDig # NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v # dENBLmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCg # v0NcVec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQT # SnovLbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh # 65ZyoUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSw # uKFWjuyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAO # QGPFmCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjD # TZ9ztwGpn1eqXijiuZQwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0G # CSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp # bWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290 # IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYT # AkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28g # UHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IB # jwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEe # AEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGV # oYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk # 8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzh # P06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41 # aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+ # ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/ # 2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9g # Rvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhA # QnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8B # Af8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcD # AzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyG # Omh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5n # Um9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8v # Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYu # cDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG # 9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW # 4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIh # rCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYp # mlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7Dc # ML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yE # Lg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRz # NyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw # 4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6 # E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6 # xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZt # g0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggY+ # MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqGSIb3DQEBDAUAMFQxCzAJ # BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl # Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjYwMjA5MDAwMDAw # WhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLQ29ubmVj # dGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcwFQYDVQQDDA5KYXNvbiBB # bGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPN6aN4B1yYW # kI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyEtRYPxEmNJL3A38Bkg7mw # zPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d67MlJLUAEufl18tOr3ST1 # DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2h4KdtRAnCqwM+/V7iBdb # w+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAADLdjZr5ip8vIWbf857Yw1 # Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZBBL56l4YAlIVRsrOiE1k # dHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDeyIq+Oztz1LvEdy8Rcd0J # BY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN79CJ90UTZb2+I+tamj0d # n+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+PhhamOxF2px9LObhBLLEMyRs # CHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi2ttn6lLOPThXMiQaooRU # q6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSMjDSRFDfHRCdGbZsL/keE # LJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJMIIBhTAfBgNVHSMEGDAW # gBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU6YF0o0D5AVhKHbVocr8G # aSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI # KwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIBAwIwJTAjBggrBgEFBQcC # ARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQQBMEkGA1UdHwRCMEAw # PqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVT # aWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBEBggrBgEFBQcwAoY4aHR0 # cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25pbmdDQVIz # Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG # SIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvCwOA/RYQnFJD7R/bSQRu3 # wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03J5PrDTp1WPCWbXKX8Oz9 # vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9URrpiLPJ9rQjfHMb1BUd # vaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6sX93wphwJNBJAstH9Ozs6 # LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+zzieGEI50fKnN77FfNKFO # WKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcjGSHw4lTg1Wx+WMAMdl/7 # nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjsNrsk9ZlalBi5SHkIu+F6 # g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1nSX8OM++AXoyVGO+iTj7 # z+pl6XE9Gw/Td6WKKKswgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0G # CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEy # NTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHT # CphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPh # of6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mA # xAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBv # MgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps # 0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF # 83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXi # UOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOM # CZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydP # pOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrU # G2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+ # sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s # BwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WI # GjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+ # IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8M # yb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2 # th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjaj # V/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2 # Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFze # GxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG # 7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+N # Jpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckT # etiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszW # kPZPubdcMIIG7TCCBNWgAwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0B # AQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/ # BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg # U0hBMjU2IDIwMjUgQ0ExMB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVow # YzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQD # EzJEaWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIw # MjUgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5g # VrMrV7pvUf+GcAoB38o3zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN # +vo8dE2/pPvOx/Vj8TchTySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qo # me7MrxVyfQO9sMx6ZAWjFDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ/ # /nBZZREr4h/GI6Dxb2UoyrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouT # MYFOnHoRh6+86Ltc5zjPKHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8 # DD+nigNJFmt6LAHvH3KSuNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnp # JeItK/DhKbPxTTuGoX7wJNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP5 # 1ho1zBp+xUIZkpSFA8vWdoUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49 # kPmk8iyyizNDIXj//cOgrY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5P # WPsWeupWs7NpChUk555K096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7Y # ufAkprxMiXAJQ1XCmnCfgPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAA # MB0GA1UdDgQWBBTkO/zyMe39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK # 6eQGfHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYI # KwYBBQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8v # b2NzcC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRp # Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZT # SEEyNTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5k # aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2 # U0hBMjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9 # bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP # 2zWLpQq1b4URGnwWBdEZD9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O # 6Lgjg8K8elC4+oWCqnU/ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskg # iC3QYIUP2S3HQvHG1FDu+WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMU # BaB5bdrPbF6MRYs03h4obEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDF # kxUGtMTaiLR9wjxUxu2hECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+ # zJNEsuEB7O7/cuvTQasnM9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/l # wd6UArb+BOVAkg2oOvol/DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxl # RcGG0LIhp6GvReQGgMgYxQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2 # zRWVFjF7mcr4C34Mj3ocCVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJg # baP5t2nGj/ULLi49xTcBZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOC # IUjsarfNZzGCBkQwggZAAgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1Nl # Y3RpZ28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWdu # aW5nIENBIFIzNgIQB5zg5NEUf4XNOXPPdi036zANBglghkgBZQMEAgEFAKCBhDAY # BgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3 # AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEi # BCA1Oza9HOozPymJ7KATeQDf99Z14G7tESihMLWCePB8RDANBgkqhkiG9w0BAQEF # AASCAgB1lLEt8FKdnnnPYCKrDHJIVUXizYr07H5IrDW+m4MS1yzf0RgSZSNHOQHi # KCwXzN2hYw3x5/UprkbKZIl1eE9B6PXTHzAGDaKFbEUW2jXdr+ZpX3B1Cy22ts7j # cuYZcnWBn6veQ74CHlSJ8fj4KkCayrO4KeSVpHqaRr2UiuvyUTxQFKo4k5pgDFGP # i72oQBHiohWrTNFKgjYwbTFKEWma4CSITzECwtoLgbtkpWnrsB1BYVe9lJdvvpnX # 9eoU4GGEVzZAkKv5EQohxjJJgPPsLMloNpY0A4MPuXMgjyue4npe80CB3MfQ6ZaT # 2r8h3g03ix/zBCIsW5XOKKn4JztOt+OXXmrY91AZwLiGl2V+dv4cMp8iDB2UjSF7 # Jw8Q7Ev/f4BalO9kqOvLxCPMLImZc2R5KENfx1f3VvGkmmbXqJ+QFSb819aHsJG9 # A5K5y3oirmOkOp+Dggumw87YinL2/FmARCnfPW1+R1HFR+OqFBWK083MwIqr1O3N # EnxJJtNIMRS9WhGt+qErBt9LRDGVJxRMVTUfl4H/GKCpzjAnnPjo7IPRi1szEhmD # /wHaj1ghSlP2NihtbXFluVDLXnBP9+mp2xKsLL+naHKDfgQvHBbcmDtA8GA/PkMb # ji+qrej8/GqyWneYy525BansH5kKwXUP4iv4xVKwAmlILU5z96GCAyYwggMiBgkq # hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E # aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l # U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeV # dGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG # CSqGSIb3DQEJBTEPFw0yNjA3MDcxODA4MzJaMC8GCSqGSIb3DQEJBDEiBCCLWlfG # tU/oeiaKqj/b7yI7MRZngEZ3iWXeRV8uIF8BtjANBgkqhkiG9w0BAQEFAASCAgB+ # 3MpehpPQPJYqjYhCoTeh/5eHCzvQmQyAeMZJgyX6sFFtwiJ7J/SI9QBj5teJewwW # RvWOArBvbU/5G/NTk83Xgx+Ts778vdEBcvrCorOw2beiRgd/vTUAPECneSAilXsu # c9TaMk1/3e1D02nEvgM1rPtA5qRKU6tDmHk+RCObNUfnCwvy7EQ06e+oHnZ2cRyo # RbHvYH6NYnWCuFGHIdcrP7a5BM40e6f5j6PQ8hTeEMnrLsDLV3xBMf8IvqSPZ1No # 8PV1975d3wtyoOa4xygE10p8pQBl+FEFOWgAODEebn2zbV7WEd25lfsXZSpcgjEE # Xo9P9jrFspMEgcF5TaSywLznqAVF2Amdb604pOZ453X8jI0RDWCg43KUArJeezn8 # ALbvBEcanjJ0YI3Gip+1iKNQlUo/iv8pe1w4eW92FpKs7YCCHHmHPeyQI+SLrzT4 # A73zFuhQiU9X+0CIutAZVkOeRtLWygBQnSUdl+Qtd0rrXPhRRnZLVJF2TMXUZFEb # WFotE7UGEPXolDj0DPm3iO1Ftwl3cECXnZo87wrwmVRSqqCWV1JhuCSh2CEUii2y # 1gm1wJgZJNKerVOGttMhzn2Eo3xfi8pWMZXUckpmqKvJewrPIlrYzmWV9wvXJGVc # YRz4QJITbYQC6d/XS2h6pgEs7yCaVudi+3VYrmUv/A== # SIG # End signature block |