Tests/Infra-RunbookEngine.Tests.ps1
|
BeforeAll { $modulePath = Split-Path -Path $PSScriptRoot -Parent Import-Module $modulePath -Force # Set up test directories $script:testRoot = Join-Path $env:TEMP "RunbookEngineTests_$(Get-Random)" $script:testExecPath = Join-Path $script:testRoot 'executions' $script:testLearningsPath = Join-Path $script:testRoot 'learnings.json' $script:testRunbooksPath = Join-Path $script:testRoot 'runbooks' $script:testMaintenancePath = Join-Path $script:testRoot 'maintenance-windows.json' New-Item -Path $script:testExecPath -ItemType Directory -Force | Out-Null New-Item -Path $script:testRunbooksPath -ItemType Directory -Force | Out-Null '[]' | Set-Content -Path $script:testLearningsPath -Encoding UTF8 # Create a valid test runbook $script:validRunbook = @" name: Test Runbook version: "1.0" description: A test runbook for unit testing trigger: metric: test_metric threshold: 50 parameters: - name: ComputerName required: true - name: TestParam default: default_value steps: - id: step_one action: script description: First test step script: | Write-Output "Testing on ComputerName" outputs: - test_output - id: step_decision action: decision description: Make a decision condition: "1 -eq 1" if_true: step_true_path if_false: step_false_path - id: step_true_path action: notify description: True path notification message: "Decision was true for ComputerName" - id: step_false_path action: escalate description: False path escalation priority: medium message: "Decision was false" "@ $script:validRunbookPath = Join-Path $script:testRunbooksPath 'test-runbook.yml' $script:validRunbook | Set-Content -Path $script:validRunbookPath -Encoding UTF8 # Create invalid runbook (no name) $script:invalidRunbookNoName = @" version: "1.0" steps: - id: step1 action: script description: test script: | Write-Output "test" "@ $script:invalidRunbookNoNamePath = Join-Path $script:testRunbooksPath 'invalid-no-name.yml' $script:invalidRunbookNoName | Set-Content -Path $script:invalidRunbookNoNamePath -Encoding UTF8 # Create invalid runbook (no steps) $script:invalidRunbookNoSteps = @" name: Bad Runbook version: "1.0" "@ $script:invalidRunbookNoStepsPath = Join-Path $script:testRunbooksPath 'invalid-no-steps.yml' $script:invalidRunbookNoSteps | Set-Content -Path $script:invalidRunbookNoStepsPath -Encoding UTF8 # Create invalid runbook (step missing action) $script:invalidRunbookNoAction = @" name: Bad Runbook 2 steps: - id: step1 description: missing action field "@ $script:invalidRunbookNoActionPath = Join-Path $script:testRunbooksPath 'invalid-no-action.yml' $script:invalidRunbookNoAction | Set-Content -Path $script:invalidRunbookNoActionPath -Encoding UTF8 # Create a runbook with approval step $script:approvalRunbook = @" name: Approval Test Runbook version: "1.0" description: Tests approval flow parameters: - name: ComputerName required: true steps: - id: do_stuff action: script description: Do something that needs approval requires_approval: true blast_radius: single_service script: | Write-Output "Approved action" "@ $script:approvalRunbookPath = Join-Path $script:testRunbooksPath 'approval-test.yml' $script:approvalRunbook | Set-Content -Path $script:approvalRunbookPath -Encoding UTF8 # Create a runbook with integration step $script:integrationRunbook = @" name: Integration Test Runbook version: "1.0" description: Tests integration steps parameters: - name: ComputerName required: true steps: - id: integrate action: integration description: Call another module module: FakeModule function: Get-FakeData parameters: ComputerName: ComputerName outputs: - fake_data - id: done action: notify description: Done message: "Integration complete" "@ $script:integrationRunbookPath = Join-Path $script:testRunbooksPath 'integration-test.yml' $script:integrationRunbook | Set-Content -Path $script:integrationRunbookPath -Encoding UTF8 } AfterAll { if (Test-Path $script:testRoot) { Remove-Item -Path $script:testRoot -Recurse -Force -ErrorAction SilentlyContinue } } Describe 'Module Structure' { It 'Should import the module without errors' { { Import-Module (Split-Path -Path $PSScriptRoot -Parent) -Force } | Should -Not -Throw } It 'Should export Invoke-Runbook' { Get-Command -Module 'Infra-RunbookEngine' -Name 'Invoke-Runbook' | Should -Not -BeNullOrEmpty } It 'Should export New-Runbook' { Get-Command -Module 'Infra-RunbookEngine' -Name 'New-Runbook' | Should -Not -BeNullOrEmpty } It 'Should export Get-RunbookStatus' { Get-Command -Module 'Infra-RunbookEngine' -Name 'Get-RunbookStatus' | Should -Not -BeNullOrEmpty } It 'Should export Get-CIHealthScore' { Get-Command -Module 'Infra-RunbookEngine' -Name 'Get-CIHealthScore' | Should -Not -BeNullOrEmpty } It 'Should export Get-ShiftHandoff' { Get-Command -Module 'Infra-RunbookEngine' -Name 'Get-ShiftHandoff' | Should -Not -BeNullOrEmpty } It 'Should not export private functions' { Get-Command -Module 'Infra-RunbookEngine' -Name 'Read-RunbookDefinition' -ErrorAction SilentlyContinue | Should -BeNullOrEmpty } It 'Should have exactly 5 public functions' { $commands = Get-Command -Module 'Infra-RunbookEngine' $commands.Count | Should -Be 5 } } Describe 'Read-RunbookDefinition - YAML Parsing' { It 'Should parse a valid runbook YAML file' { $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:validRunbookPath } { param($Path) Read-RunbookDefinition -Path $Path } $result | Should -Not -BeNullOrEmpty $result.Name | Should -Be 'Test Runbook' } It 'Should parse version field' { $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:validRunbookPath } { param($Path) Read-RunbookDefinition -Path $Path } $result.Version | Should -Be '1.0' } It 'Should parse description field' { $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:validRunbookPath } { param($Path) Read-RunbookDefinition -Path $Path } $result.Description | Should -Be 'A test runbook for unit testing' } It 'Should parse all steps' { $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:validRunbookPath } { param($Path) Read-RunbookDefinition -Path $Path } $result.Steps.Count | Should -BeGreaterOrEqual 3 } It 'Should parse step id and action fields' { $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:validRunbookPath } { param($Path) Read-RunbookDefinition -Path $Path } $firstStep = $result.Steps[0] $firstStep.id | Should -Be 'step_one' $firstStep.action | Should -Be 'script' } It 'Should throw for missing file' { { InModuleScope 'Infra-RunbookEngine' { Read-RunbookDefinition -Path 'C:\nonexistent\fake.yml' } } | Should -Throw '*not found*' } It 'Should throw for runbook without name' { { InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:invalidRunbookNoNamePath } { param($Path) Read-RunbookDefinition -Path $Path } } | Should -Throw "*'name' field is required*" } It 'Should throw for runbook without steps' { { InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:invalidRunbookNoStepsPath } { param($Path) Read-RunbookDefinition -Path $Path } } | Should -Throw "*'steps' field is required*" } It 'Should throw for step without action field' { { InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:invalidRunbookNoActionPath } { param($Path) Read-RunbookDefinition -Path $Path } } | Should -Throw "*must have an 'action' field*" } It 'Should parse parameters list' { $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:validRunbookPath } { param($Path) Read-RunbookDefinition -Path $Path } $result.Parameters | Should -Not -BeNullOrEmpty } It 'Should return source path' { $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $script:validRunbookPath } { param($Path) Read-RunbookDefinition -Path $Path } $result.SourcePath | Should -Be $script:validRunbookPath } } Describe 'Decision Tree Navigation' { It 'Should follow if_true path when condition is true' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $decisionStep = $result.StepResults | Where-Object { $_.StepId -eq 'step_decision' } $decisionStep | Should -Not -BeNullOrEmpty } It 'Should execute the true-path step after a true decision' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $trueStep = $result.StepResults | Where-Object { $_.StepId -eq 'step_true_path' } $trueStep | Should -Not -BeNullOrEmpty } It 'Should execute the true-path step before the false-path step in WhatIf' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $trueIdx = ($result.StepResults | ForEach-Object { $_.StepId }).IndexOf('step_true_path') $trueIdx | Should -BeGreaterOrEqual 0 # The true path should appear in the results (decision followed true branch) $decisionStep = $result.StepResults | Where-Object { $_.StepId -eq 'step_decision' } $decisionStep | Should -Not -BeNullOrEmpty } It 'Should record step durations' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf foreach ($step in $result.StepResults) { $step.Duration | Should -Match '\d+\.\d+s' } } } Describe 'Test-BlastRadius' { It 'Should return Low level for unknown servers when step has single_service blast radius' { $step = [PSCustomObject]@{ blast_radius = 'single_service'; requires_approval = $false } $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Step = $step } { param($Step) Test-BlastRadius -ComputerName 'UNKNOWNSERVER' -Action 'Test action' -RunbookStep $Step } $result.Level | Should -Be 'Low' } It 'Should return Medium level for single_server blast radius' { $step = [PSCustomObject]@{ blast_radius = 'single_server'; requires_approval = $true } $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Step = $step } { param($Step) Test-BlastRadius -ComputerName 'TESTSERVER' -Action 'Test action' -RunbookStep $Step } $result.Level | Should -Be 'Medium' } It 'Should set RequiresApproval to true for High blast radius' { $step = [PSCustomObject]@{ blast_radius = 'multi_server'; requires_approval = $true } $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Step = $step } { param($Step) Test-BlastRadius -ComputerName 'TESTSERVER' -Action 'Test action' -RunbookStep $Step } $result.RequiresApproval | Should -Be $true } It 'Should set Critical level for domain_wide blast radius' { $step = [PSCustomObject]@{ blast_radius = 'domain_wide'; requires_approval = $true } $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Step = $step } { param($Step) Test-BlastRadius -ComputerName 'DC01' -Action 'Domain action' -RunbookStep $Step } $result.Level | Should -Be 'Critical' } It 'Should include ComputerName in the result' { $result = InModuleScope 'Infra-RunbookEngine' { Test-BlastRadius -ComputerName 'MYSERVER' -Action 'Test' } $result.ComputerName | Should -Be 'MYSERVER' } It 'Should include AssessedAt timestamp' { $result = InModuleScope 'Infra-RunbookEngine' { Test-BlastRadius -ComputerName 'MYSERVER' -Action 'Test' } $result.AssessedAt | Should -Not -BeNullOrEmpty } It 'Should set RequiresApproval when step requires_approval is true' { $step = [PSCustomObject]@{ blast_radius = 'single_service'; requires_approval = $true } $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Step = $step } { param($Step) Test-BlastRadius -ComputerName 'TESTSERVER' -Action 'Test' -RunbookStep $Step } $result.RequiresApproval | Should -Be $true } } Describe 'Test-MaintenanceWindow' { BeforeEach { $script:testMWPath = Join-Path $script:testRoot 'mw-test.json' } It 'Should return false when no maintenance windows configured' { '[]' | Set-Content -Path $script:testMWPath -Encoding UTF8 $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ ConfigPath = $script:testMWPath } { param($ConfigPath) Test-MaintenanceWindow -ComputerName 'SERVER01' -ConfigPath $ConfigPath } $result.InWindow | Should -Be $false } It 'Should return true when computer is in a one-time window' { $now = Get-Date $windows = @( @{ ComputerName = 'SERVER01' Name = 'Test Window' Start = $now.AddHours(-1).ToString('o') End = $now.AddHours(1).ToString('o') Recurring = $false } ) $windows | ConvertTo-Json -Depth 5 | Set-Content -Path $script:testMWPath -Encoding UTF8 $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ ConfigPath = $script:testMWPath } { param($ConfigPath) Test-MaintenanceWindow -ComputerName 'SERVER01' -ConfigPath $ConfigPath } $result.InWindow | Should -Be $true } It 'Should return false when one-time window has expired' { $now = Get-Date $windows = @( @{ ComputerName = 'SERVER01' Name = 'Expired Window' Start = $now.AddHours(-3).ToString('o') End = $now.AddHours(-1).ToString('o') Recurring = $false } ) $windows | ConvertTo-Json -Depth 5 | Set-Content -Path $script:testMWPath -Encoding UTF8 $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ ConfigPath = $script:testMWPath } { param($ConfigPath) Test-MaintenanceWindow -ComputerName 'SERVER01' -ConfigPath $ConfigPath } $result.InWindow | Should -Be $false } It 'Should match using Pattern wildcard' { $now = Get-Date $windows = @( @{ Pattern = 'WEB*' Name = 'Web Server Window' Start = $now.AddHours(-1).ToString('o') End = $now.AddHours(1).ToString('o') Recurring = $false } ) $windows | ConvertTo-Json -Depth 5 | Set-Content -Path $script:testMWPath -Encoding UTF8 $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ ConfigPath = $script:testMWPath } { param($ConfigPath) Test-MaintenanceWindow -ComputerName 'WEB01' -ConfigPath $ConfigPath } $result.InWindow | Should -Be $true } It 'Should return false when config file does not exist' { $result = InModuleScope 'Infra-RunbookEngine' { Test-MaintenanceWindow -ComputerName 'SERVER01' -ConfigPath 'C:\nonexistent\fake.json' } $result.InWindow | Should -Be $false } It 'Should handle recurring window on correct day' { $now = Get-Date $today = $now.DayOfWeek.ToString() $windows = @( @{ ComputerName = 'SERVER01' Name = 'Weekly Maintenance' Start = $now.AddHours(-1).ToString('HH:mm') End = $now.AddHours(1).ToString('HH:mm') Recurring = $true DayOfWeek = $today } ) $windows | ConvertTo-Json -Depth 5 | Set-Content -Path $script:testMWPath -Encoding UTF8 $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ ConfigPath = $script:testMWPath } { param($ConfigPath) Test-MaintenanceWindow -ComputerName 'SERVER01' -ConfigPath $ConfigPath } $result.InWindow | Should -Be $true $result.IsRecurring | Should -Be $true } } Describe 'Get-CIHealthScore - Health Score Calculation' { It 'Should return a score between 0 and 100' { $result = Get-CIHealthScore -ComputerName 'TESTPC' $result.Score | Should -BeGreaterOrEqual 0 $result.Score | Should -BeLessOrEqual 100 } It 'Should return a valid grade (A-F)' { $result = Get-CIHealthScore -ComputerName 'TESTPC' $result.Grade | Should -BeIn @('A', 'B', 'C', 'D', 'F') } It 'Should include ComputerName in result' { $result = Get-CIHealthScore -ComputerName 'TESTPC' $result.ComputerName | Should -Be 'TESTPC' } It 'Should return A grade for score 90-100' { $result = Get-CIHealthScore -ComputerName 'TESTPC' if ($result.Score -ge 90) { $result.Grade | Should -Be 'A' } elseif ($result.Score -ge 80) { $result.Grade | Should -Be 'B' } # Just verify the grading logic works $result.Grade | Should -Not -BeNullOrEmpty } It 'Should include Trend field' { $result = Get-CIHealthScore -ComputerName 'TESTPC' $result.Trend | Should -BeIn @('Improving', 'Declining', 'Stable') } It 'Should include DaysBack parameter in result' { $result = Get-CIHealthScore -ComputerName 'TESTPC' -DaysBack 7 $result.DaysBack | Should -Be 7 } It 'Should include AssessedAt timestamp' { $result = Get-CIHealthScore -ComputerName 'TESTPC' $result.AssessedAt | Should -Not -BeNullOrEmpty } It 'Should handle multiple computers' { $results = Get-CIHealthScore -ComputerName 'TESTPC1', 'TESTPC2' @($results).Count | Should -Be 2 } It 'Should subtract points for failed runbook executions' { # Create a fake failed execution $fakeExec = @{ ExecutionId = [guid]::NewGuid().ToString() RunbookName = 'test' ComputerName = 'FAILPC' Status = 'Failed' StartTime = (Get-Date).ToString('o') } $fakeExecPath = Join-Path $script:testExecPath "$($fakeExec.ExecutionId).json" $fakeExec | ConvertTo-Json -Depth 5 | Set-Content -Path $fakeExecPath -Encoding UTF8 $result = Get-CIHealthScore -ComputerName 'FAILPC' -IncludeRunbookHistory # The score should be less than perfect if failure is detected # (depends on execution path finding the test directory) $result.Score | Should -BeLessOrEqual 100 } } Describe 'Update-RunbookLearning - Learning Loop' { It 'Should record a successful step' { $testLearnPath = Join-Path $script:testRoot 'test-learnings.json' '[]' | Set-Content -Path $testLearnPath -Encoding UTF8 $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ LearnPath = $testLearnPath } { param($LearnPath) Update-RunbookLearning -RunbookName 'test-rb' -StepId 'step1' -Action 'script' ` -Succeeded $true -ComputerName 'SVR01' -LearningsPath $LearnPath } $result.Successes | Should -Be 1 $result.TotalRuns | Should -Be 1 $result.SuccessRate | Should -Be 100.0 } It 'Should record a failed step' { $testLearnPath = Join-Path $script:testRoot 'test-learnings2.json' '[]' | Set-Content -Path $testLearnPath -Encoding UTF8 $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ LearnPath = $testLearnPath } { param($LearnPath) Update-RunbookLearning -RunbookName 'test-rb' -StepId 'step1' -Action 'script' ` -Succeeded $false -ComputerName 'SVR01' -Context @{ Error = 'Test error' } -LearningsPath $LearnPath } $result.Failures | Should -Be 1 $result.SuccessRate | Should -Be 0.0 } It 'Should accumulate runs on existing entries' { $testLearnPath = Join-Path $script:testRoot 'test-learnings3.json' '[]' | Set-Content -Path $testLearnPath -Encoding UTF8 InModuleScope 'Infra-RunbookEngine' -Parameters @{ LearnPath = $testLearnPath } { param($LearnPath) Update-RunbookLearning -RunbookName 'test-rb' -StepId 'step1' -Action 'script' ` -Succeeded $true -ComputerName 'SVR01' -LearningsPath $LearnPath } $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ LearnPath = $testLearnPath } { param($LearnPath) Update-RunbookLearning -RunbookName 'test-rb' -StepId 'step1' -Action 'script' ` -Succeeded $false -ComputerName 'SVR01' -LearningsPath $LearnPath } $result.TotalRuns | Should -Be 2 $result.Successes | Should -Be 1 $result.Failures | Should -Be 1 $result.SuccessRate | Should -Be 50.0 } It 'Should track failure patterns' { $testLearnPath = Join-Path $script:testRoot 'test-learnings4.json' '[]' | Set-Content -Path $testLearnPath -Encoding UTF8 $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ LearnPath = $testLearnPath } { param($LearnPath) Update-RunbookLearning -RunbookName 'test-rb' -StepId 'step1' -Action 'script' ` -Succeeded $false -ComputerName 'SVR01' -Context @{ Error = 'Connection refused' } -LearningsPath $LearnPath } $result.FailurePatterns | Should -Not -BeNullOrEmpty @($result.FailurePatterns).Count | Should -BeGreaterOrEqual 1 } It 'Should persist learnings to JSON file' { $testLearnPath = Join-Path $script:testRoot 'test-learnings5.json' '[]' | Set-Content -Path $testLearnPath -Encoding UTF8 InModuleScope 'Infra-RunbookEngine' -Parameters @{ LearnPath = $testLearnPath } { param($LearnPath) Update-RunbookLearning -RunbookName 'persist-test' -StepId 'step1' -Action 'script' ` -Succeeded $true -ComputerName 'SVR01' -LearningsPath $LearnPath } $content = Get-Content -Path $testLearnPath -Raw | ConvertFrom-Json @($content).Count | Should -BeGreaterOrEqual 1 @($content)[0].RunbookName | Should -Be 'persist-test' } } Describe 'Find-CorrelatedIssues - Correlation Detection' { BeforeEach { $script:testCorrelationPath = Join-Path $script:testRoot 'correlation-execs' if (Test-Path $script:testCorrelationPath) { Remove-Item $script:testCorrelationPath -Recurse -Force } New-Item -Path $script:testCorrelationPath -ItemType Directory -Force | Out-Null } It 'Should return no correlations when no executions exist' { $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ ExecPath = $script:testCorrelationPath } { param($ExecPath) Find-CorrelatedIssues -ComputerName 'SVR01' -Symptom 'test' -ExecutionsPath $ExecPath } $result.CorrelationsFound | Should -Be 0 } It 'Should detect DNS + replication correlation pattern' { $execs = @( @{ ExecutionId = [guid]::NewGuid().ToString() RunbookName = 'dns-resolution' ComputerName = 'DC01' Status = 'Failed' StartTime = (Get-Date).AddMinutes(-10).ToString('o') } @{ ExecutionId = [guid]::NewGuid().ToString() RunbookName = 'replication-failure' ComputerName = 'DC01' Status = 'Failed' StartTime = (Get-Date).AddMinutes(-5).ToString('o') } ) foreach ($exec in $execs) { $path = Join-Path $script:testCorrelationPath "$($exec.ExecutionId).json" $exec | ConvertTo-Json -Depth 5 | Set-Content -Path $path -Encoding UTF8 } $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ ExecPath = $script:testCorrelationPath } { param($ExecPath) Find-CorrelatedIssues -ComputerName 'DC01' -Symptom 'DNS' -ExecutionsPath $ExecPath } $result.CorrelationsFound | Should -BeGreaterOrEqual 1 $result.PossibleRootCause | Should -Not -BeNullOrEmpty } It 'Should return no correlation for unrelated runbooks' { $exec = @{ ExecutionId = [guid]::NewGuid().ToString() RunbookName = 'disk-space' ComputerName = 'FILE01' Status = 'Completed' StartTime = (Get-Date).AddMinutes(-10).ToString('o') } $path = Join-Path $script:testCorrelationPath "$($exec.ExecutionId).json" $exec | ConvertTo-Json -Depth 5 | Set-Content -Path $path -Encoding UTF8 $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ ExecPath = $script:testCorrelationPath } { param($ExecPath) Find-CorrelatedIssues -ComputerName 'WEB01' -Symptom 'disk' -ExecutionsPath $ExecPath } $result.CorrelationsFound | Should -Be 0 } It 'Should detect multiple failures pattern' { $execs = @( @{ ExecutionId = [guid]::NewGuid().ToString(); RunbookName = 'test1'; ComputerName = 'SVR01'; Status = 'Failed'; StartTime = (Get-Date).AddMinutes(-10).ToString('o') } @{ ExecutionId = [guid]::NewGuid().ToString(); RunbookName = 'test2'; ComputerName = 'SVR01'; Status = 'Failed'; StartTime = (Get-Date).AddMinutes(-8).ToString('o') } @{ ExecutionId = [guid]::NewGuid().ToString(); RunbookName = 'test3'; ComputerName = 'SVR01'; Status = 'Failed'; StartTime = (Get-Date).AddMinutes(-5).ToString('o') } ) foreach ($exec in $execs) { $path = Join-Path $script:testCorrelationPath "$($exec.ExecutionId).json" $exec | ConvertTo-Json -Depth 5 | Set-Content -Path $path -Encoding UTF8 } $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ ExecPath = $script:testCorrelationPath } { param($ExecPath) Find-CorrelatedIssues -ComputerName 'SVR01' -Symptom 'multiple' -ExecutionsPath $ExecPath } $result.CorrelationsFound | Should -BeGreaterOrEqual 1 } } Describe 'Invoke-Runbook - WhatIf Mode' { It 'Should not execute scripts in WhatIf mode' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $result.WhatIf | Should -Be $true } It 'Should set WhatIf flag on the result' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $result.WhatIf | Should -Be $true } It 'Should include step results even in WhatIf mode' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $result.StepResults | Should -Not -BeNullOrEmpty @($result.StepResults).Count | Should -BeGreaterOrEqual 1 } It 'Should not create an execution log file in WhatIf mode' { $beforeFiles = @(Get-ChildItem -Path (Join-Path $env:USERPROFILE '.runbookengine\executions') -Filter '*.json' -ErrorAction SilentlyContinue) Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $afterFiles = @(Get-ChildItem -Path (Join-Path $env:USERPROFILE '.runbookengine\executions') -Filter '*.json' -ErrorAction SilentlyContinue) $afterFiles.Count | Should -Be $beforeFiles.Count } } Describe 'Invoke-Runbook - Parameter Validation' { It 'Should require RunbookName parameter' { { Invoke-Runbook -ComputerName 'TESTPC' } | Should -Throw } It 'Should throw for non-existent runbook' { { Invoke-Runbook -RunbookName 'nonexistent-runbook-xyz' -ComputerName 'TESTPC' } | Should -Throw '*not found*' } It 'Should accept a file path as RunbookName' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $result | Should -Not -BeNullOrEmpty } It 'Should return result with ExecutionId' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $result.ExecutionId | Should -Not -BeNullOrEmpty } It 'Should return result with RunbookName' { $result = Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -WhatIf $result.RunbookName | Should -Be 'Test Runbook' } It 'Should validate ApprovalMethod enum' { { Invoke-Runbook -RunbookName $script:validRunbookPath -ComputerName 'TESTPC' -ApprovalMethod 'InvalidMethod' } | Should -Throw } } Describe 'Test-FixVerification' { It 'Should verify a passing check' { $result = InModuleScope 'Infra-RunbookEngine' { Test-FixVerification -VerificationScript { $true } -WaitSeconds 0 -MaxRetries 1 } $result.Verified | Should -Be $true $result.Attempts | Should -Be 1 } It 'Should fail verification for a failing check' { $result = InModuleScope 'Infra-RunbookEngine' { Test-FixVerification -VerificationScript { $false } -WaitSeconds 0 -MaxRetries 1 } $result.Verified | Should -Be $false } It 'Should retry up to MaxRetries' { $result = InModuleScope 'Infra-RunbookEngine' { Test-FixVerification -VerificationScript { $false } -WaitSeconds 0 -MaxRetries 3 } $result.Attempts | Should -Be 3 } It 'Should include ComputerName in result' { $result = InModuleScope 'Infra-RunbookEngine' { Test-FixVerification -VerificationScript { $true } -WaitSeconds 0 -ComputerName 'SVR01' } $result.ComputerName | Should -Be 'SVR01' } It 'Should capture errors from verification script' { $result = InModuleScope 'Infra-RunbookEngine' { Test-FixVerification -VerificationScript { throw "Verification failed" } -WaitSeconds 0 -MaxRetries 1 } $result.Verified | Should -Be $false $result.LastError | Should -Not -BeNullOrEmpty } } Describe 'Get-ShiftHandoff' { It 'Should return a handoff report object' { $result = Get-ShiftHandoff -HoursBack 1 $result | Should -Not -BeNullOrEmpty } It 'Should include HoursBack in the result' { $result = Get-ShiftHandoff -HoursBack 4 $result.HoursBack | Should -Be 4 } It 'Should include GeneratedAt timestamp' { $result = Get-ShiftHandoff -HoursBack 1 $result.GeneratedAt | Should -Not -BeNullOrEmpty } It 'Should include execution counts' { $result = Get-ShiftHandoff -HoursBack 1 $result.TotalExecutions | Should -BeGreaterOrEqual 0 $result.Completed | Should -BeGreaterOrEqual 0 $result.Failed | Should -BeGreaterOrEqual 0 } It 'Should include NextShiftNotes array' { $result = Get-ShiftHandoff -HoursBack 1 $result.NextShiftNotes | Should -Not -BeNullOrEmpty } It 'Should generate HTML report when OutputPath is specified' { $htmlPath = Join-Path $script:testRoot 'handoff-test.html' $result = Get-ShiftHandoff -HoursBack 1 -OutputPath $htmlPath Test-Path $htmlPath | Should -Be $true } } Describe 'New-HtmlDashboard - HTML Report Generation' { It 'Should generate an execution report HTML file' { $data = [PSCustomObject]@{ RunbookName = 'test-runbook' ComputerName = 'SVR01' Status = 'Completed' Duration = '5.2s' StepResults = @( [PSCustomObject]@{ StepId = 'step1'; Action = 'script'; Description = 'Test step'; Status = 'Success'; Output = 'OK'; Duration = '1.0s' } ) ApprovalLog = @() VerificationResults = @() } $htmlPath = Join-Path $script:testRoot 'exec-report.html' $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Data = $data; Path = $htmlPath } { param($Data, $Path) New-HtmlDashboard -ReportType 'ExecutionReport' -Data $Data -OutputPath $Path } Test-Path $htmlPath | Should -Be $true } It 'Should include accent color #f97316 in the HTML' { $data = [PSCustomObject]@{ RunbookName = 'test'; ComputerName = 'SVR01'; Status = 'Completed'; Duration = '1s' StepResults = @(); ApprovalLog = @(); VerificationResults = @() } $htmlPath = Join-Path $script:testRoot 'color-test.html' InModuleScope 'Infra-RunbookEngine' -Parameters @{ Data = $data; Path = $htmlPath } { param($Data, $Path) New-HtmlDashboard -ReportType 'ExecutionReport' -Data $Data -OutputPath $Path } $content = Get-Content -Path $htmlPath -Raw $content | Should -Match '#f97316' } It 'Should generate health score HTML' { $data = [PSCustomObject]@{ ComputerName = 'SVR01'; Score = 85; Grade = 'B'; Trend = 'Improving' Factors = @([PSCustomObject]@{ Name = 'Test'; Impact = -5; Details = 'Detail' }) Recommendations = @('Do something') } $htmlPath = Join-Path $script:testRoot 'health-report.html' InModuleScope 'Infra-RunbookEngine' -Parameters @{ Data = $data; Path = $htmlPath } { param($Data, $Path) New-HtmlDashboard -ReportType 'HealthScore' -Data $Data -OutputPath $Path } Test-Path $htmlPath | Should -Be $true $content = Get-Content -Path $htmlPath -Raw $content | Should -Match 'Health' } It 'Should use only inline CSS (no external stylesheet references)' { $data = [PSCustomObject]@{ ComputerName = 'SVR01'; Score = 90; Grade = 'A'; Trend = 'Stable' Factors = @(); Recommendations = @() } $htmlPath = Join-Path $script:testRoot 'inline-css-test.html' InModuleScope 'Infra-RunbookEngine' -Parameters @{ Data = $data; Path = $htmlPath } { param($Data, $Path) New-HtmlDashboard -ReportType 'HealthScore' -Data $Data -OutputPath $Path } $content = Get-Content -Path $htmlPath -Raw $content | Should -Not -Match 'rel="stylesheet"' $content | Should -Match '<style>' } } Describe 'New-Runbook - From Template' { It 'Should create a runbook from a built-in template' { $outputDir = Join-Path $script:testRoot 'from-template' New-Item -Path $outputDir -ItemType Directory -Force | Out-Null $result = New-Runbook -Name 'my-cpu-runbook' -FromTemplate 'high-cpu' -OutputPath $outputDir $result | Should -Not -BeNullOrEmpty Test-Path $result | Should -Be $true } It 'Should throw for non-existent template' { $outputDir = Join-Path $script:testRoot 'from-bad-template' New-Item -Path $outputDir -ItemType Directory -Force | Out-Null { New-Runbook -Name 'test' -FromTemplate 'nonexistent-template' -OutputPath $outputDir } | Should -Throw '*not found*' } It 'Should create a runbook from disk-space template' { $outputDir = Join-Path $script:testRoot 'blank-runbook' New-Item -Path $outputDir -ItemType Directory -Force | Out-Null $result = New-Runbook -Name 'my-disk-runbook' -FromTemplate 'disk-space' -OutputPath $outputDir Test-Path $result | Should -Be $true $content = Get-Content -Path $result -Raw $content | Should -Match 'name: my-disk-runbook' } } Describe 'Get-RunbookStatus' { BeforeAll { # Create test execution files $script:testStatusExec = @{ ExecutionId = 'test-status-001' RunbookName = 'status-test' ComputerName = 'STATUSSVR' Status = 'Completed' StartTime = (Get-Date).ToString('o') EndTime = (Get-Date).AddSeconds(30).ToString('o') Duration = '30.0s' StepResults = @( @{ StepId = 'step1'; Action = 'script'; Description = 'Test'; Status = 'Success'; Duration = '5.0s' } ) } $execPath = Join-Path $env:USERPROFILE '.runbookengine\executions' if (-not (Test-Path $execPath)) { New-Item -Path $execPath -ItemType Directory -Force | Out-Null } $script:testStatusExec | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $execPath 'test-status-001.json') -Encoding UTF8 } It 'Should return execution by ID' { $result = Get-RunbookStatus -ExecutionId 'test-status-001' $result | Should -Not -BeNullOrEmpty } It 'Should return empty array when no executions exist for a filter' { $result = Get-RunbookStatus -ComputerName 'NONEXISTENT999' -Status 'Failed' @($result).Count | Should -Be 0 } It 'Should support -Last parameter' { $result = Get-RunbookStatus -Last 5 @($result).Count | Should -BeLessOrEqual 5 } It 'Should support -Since parameter' { $result = Get-RunbookStatus -Since (Get-Date).AddDays(-1) # Should return results including our test execution $result | Should -Not -BeNullOrEmpty } AfterAll { $cleanupPath = Join-Path $env:USERPROFILE '.runbookengine\executions\test-status-001.json' if (Test-Path $cleanupPath) { Remove-Item $cleanupPath -Force -ErrorAction SilentlyContinue } } } Describe 'YAML Templates' { BeforeAll { $script:templateDir = Join-Path (Split-Path -Path $PSScriptRoot -Parent) 'Templates' } It 'Should have high-cpu.yml template' { Test-Path (Join-Path $script:templateDir 'high-cpu.yml') | Should -Be $true } It 'Should parse high-cpu.yml without errors' { $path = Join-Path $script:templateDir 'high-cpu.yml' $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $path } { param($Path) Read-RunbookDefinition -Path $Path } $result.Name | Should -Not -BeNullOrEmpty $result.Steps | Should -Not -BeNullOrEmpty } It 'Should have disk-space.yml template' { Test-Path (Join-Path $script:templateDir 'disk-space.yml') | Should -Be $true } It 'Should parse disk-space.yml without errors' { $path = Join-Path $script:templateDir 'disk-space.yml' $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $path } { param($Path) Read-RunbookDefinition -Path $Path } $result.Name | Should -Not -BeNullOrEmpty $result.Steps | Should -Not -BeNullOrEmpty } It 'Should have service-recovery.yml template' { Test-Path (Join-Path $script:templateDir 'service-recovery.yml') | Should -Be $true } It 'Should parse service-recovery.yml without errors' { $path = Join-Path $script:templateDir 'service-recovery.yml' $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $path } { param($Path) Read-RunbookDefinition -Path $Path } $result.Name | Should -Not -BeNullOrEmpty $result.Steps | Should -Not -BeNullOrEmpty } It 'Should have certificate-expiry.yml template' { Test-Path (Join-Path $script:templateDir 'certificate-expiry.yml') | Should -Be $true } It 'Should parse certificate-expiry.yml without errors' { $path = Join-Path $script:templateDir 'certificate-expiry.yml' $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $path } { param($Path) Read-RunbookDefinition -Path $Path } $result.Name | Should -Not -BeNullOrEmpty $result.Steps | Should -Not -BeNullOrEmpty } It 'Should have dns-resolution.yml template' { Test-Path (Join-Path $script:templateDir 'dns-resolution.yml') | Should -Be $true } It 'Should parse dns-resolution.yml without errors' { $path = Join-Path $script:templateDir 'dns-resolution.yml' $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $path } { param($Path) Read-RunbookDefinition -Path $Path } $result.Name | Should -Not -BeNullOrEmpty $result.Steps | Should -Not -BeNullOrEmpty } It 'Should have replication-failure.yml template' { Test-Path (Join-Path $script:templateDir 'replication-failure.yml') | Should -Be $true } It 'Should parse replication-failure.yml without errors' { $path = Join-Path $script:templateDir 'replication-failure.yml' $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $path } { param($Path) Read-RunbookDefinition -Path $Path } $result.Name | Should -Not -BeNullOrEmpty $result.Steps | Should -Not -BeNullOrEmpty } It 'Should have backup-failure.yml template' { Test-Path (Join-Path $script:templateDir 'backup-failure.yml') | Should -Be $true } It 'Should parse backup-failure.yml without errors' { $path = Join-Path $script:templateDir 'backup-failure.yml' $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $path } { param($Path) Read-RunbookDefinition -Path $Path } $result.Name | Should -Not -BeNullOrEmpty $result.Steps | Should -Not -BeNullOrEmpty } It 'Should have memory-pressure.yml template' { Test-Path (Join-Path $script:templateDir 'memory-pressure.yml') | Should -Be $true } It 'Should parse memory-pressure.yml without errors' { $path = Join-Path $script:templateDir 'memory-pressure.yml' $result = InModuleScope 'Infra-RunbookEngine' -Parameters @{ Path = $path } { param($Path) Read-RunbookDefinition -Path $Path } $result.Name | Should -Not -BeNullOrEmpty $result.Steps | Should -Not -BeNullOrEmpty } } Describe 'Send-ApprovalRequest - Console Mode' { It 'Should create a valid approval result object structure' { # We cannot truly test Read-Host in automated tests, but we can verify the function exists # and test the output structure by mocking $result = InModuleScope 'Infra-RunbookEngine' { Mock Read-Host { return 'YES' } Send-ApprovalRequest -Method Console -RunbookName 'test' -StepDescription 'test step' -ComputerName 'SVR01' } $result.RunbookName | Should -Be 'test' $result.Method | Should -Be 'Console' $result.Approved | Should -Be $true } It 'Should deny when response is not YES' { $result = InModuleScope 'Infra-RunbookEngine' { Mock Read-Host { return 'NO' } Send-ApprovalRequest -Method Console -RunbookName 'test' -StepDescription 'test step' -ComputerName 'SVR01' } $result.Approved | Should -Be $false } } |