diff --git a/Tests/LoadMonitor/LoadMonitor.Tests.ps1 b/Tests/LoadMonitor/LoadMonitor.Tests.ps1 new file mode 100644 index 0000000..3d2abde --- /dev/null +++ b/Tests/LoadMonitor/LoadMonitor.Tests.ps1 @@ -0,0 +1,378 @@ +#Requires -Module Pester + +# Behavioral tests for the LoadMonitor module (issue #20). LoadMonitor previously had only the +# function-existence contract checks in SEBackup.Tests.ps1 (~near-zero behavioral coverage); this +# suite exercises the actual parsing, threshold, and defer/skip logic of the three public functions +# WITHOUT any real WinRM / VRage / Torch by mocking at the infrastructure boundary: +# +# * Get-SEBNodeMetrics -> mock Invoke-SEBRemoteCommand (LoadMonitor scope) to return canned +# CPU/mem/disk; assert the structured PSCustomObject shape, and that a +# remote failure is swallowed into the default result (never throws). +# * Test-SEBNodeLoad -> mock Get-SEBPlayerCount + Invoke-SEBRemoteCommand (the CPU and memory +# CIM calls, told apart by their scriptblock text) to drive under / at / +# over-threshold cases; assert CanProceed and the Reasons text. +# * Wait-SEBNodeLoad -> mock Test-SEBNodeLoad (busy-then-idle) and Start-Sleep so the defer +# loop is fully simulated (NO real sleeping): assert it waits then +# proceeds, that 'skip' returns immediately, and that a persistently +# busy node keeps polling/sleeping (the wait mechanics) rather than +# proceeding. +# +# Mocks use -ModuleName LoadMonitor (Pester 5) because the functions run in the LoadMonitor nested +# module scope when imported via the root SEBackup.psd1, so their internal calls resolve there. +# The -Session parameter is strongly typed [System.Management.Automation.Runspaces.PSSession], which +# has no public constructor, so we hand it an UNINITIALIZED instance (the repo's established double). + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # Shared PSSession double (the FormatterServices.GetUninitializedObject([PSSession]) trick, + # previously hand-rolled per-suite). New-FakeSession here returns the bare uninitialized session. + . "$PSScriptRoot/../_TestHelpers/Test-Doubles.ps1" +} + +Describe 'Get-SEBNodeMetrics' { + BeforeAll { + Mock Write-SEBLog {} -ModuleName LoadMonitor + } + + Context 'parses canned remote metrics into a structured object' { + BeforeAll { + # The function gathers everything in ONE Invoke-SEBRemoteCommand returning a hashtable. + Mock Invoke-SEBRemoteCommand -ModuleName LoadMonitor { + @{ + Cpu = @{ AveragePercent = 42.5; Processors = @(40, 45) } + Memory = @{ TotalMB = 16384; AvailableMB = 8192; UsedPercent = 50.0 } + Disks = @( + @{ DriveLetter = 'C:'; TotalGB = 200.0; FreeGB = 50.0; UsedPercent = 75.0 } + @{ DriveLetter = 'D:'; TotalGB = 500.0; FreeGB = 400.0; UsedPercent = 20.0 } + ) + } + } + $script:metrics = Get-SEBNodeMetrics -Session (New-FakeSession) + } + + It 'returns a PSCustomObject with the documented top-level shape' { + $script:metrics | Should -Not -BeNullOrEmpty + $script:metrics.PSObject.Properties.Name | Should -Contain 'Cpu' + $script:metrics.PSObject.Properties.Name | Should -Contain 'Memory' + $script:metrics.PSObject.Properties.Name | Should -Contain 'Disks' + $script:metrics.PSObject.Properties.Name | Should -Contain 'CollectedAt' + $script:metrics.CollectedAt | Should -BeOfType [datetime] + } + + It 'collects in ONE remote call, forwarding the given session and the node CIM script block' { + # Folds the old count-only "-Times 1" check into a real shape assertion: the collection + # must route through the remoting wrapper EXACTLY once, forwarding the SAME session it was + # handed and a script block that actually queries the node's CIM classes (a bare -Times 1 + # would pass even if it forwarded an empty/wrong block to a different session). The call is + # made in-scope here so the invocation count is attributable to this It. + $sess = New-FakeSession -Name 'SEBackup-node-metrics' + Get-SEBNodeMetrics -Session $sess | Out-Null + Should -Invoke Invoke-SEBRemoteCommand -ModuleName LoadMonitor -Times 1 -Exactly -ParameterFilter { + $Session.Name -eq 'SEBackup-node-metrics' -and + $ScriptBlock.ToString() -match 'Win32_Processor' -and + $ScriptBlock.ToString() -match 'Win32_OperatingSystem' -and + $ScriptBlock.ToString() -match 'Win32_LogicalDisk' + } + } + + It 'maps the CPU block (average + per-processor array)' { + $script:metrics.Cpu.AveragePercent | Should -Be 42.5 + $script:metrics.Cpu.Processors | Should -Be @(40, 45) + } + + It 'maps the memory block' { + $script:metrics.Memory.TotalMB | Should -Be 16384 + $script:metrics.Memory.AvailableMB | Should -Be 8192 + $script:metrics.Memory.UsedPercent | Should -Be 50.0 + } + + It 'maps each disk into its own object' { + @($script:metrics.Disks).Count | Should -Be 2 + $script:metrics.Disks[0].DriveLetter | Should -Be 'C:' + $script:metrics.Disks[0].UsedPercent | Should -Be 75.0 + $script:metrics.Disks[1].DriveLetter | Should -Be 'D:' + $script:metrics.Disks[1].FreeGB | Should -Be 400.0 + } + } + + Context 'a remote failure is swallowed (never throws; returns the default result)' { + BeforeAll { + Mock Invoke-SEBRemoteCommand -ModuleName LoadMonitor { throw 'WinRM transport dropped' } + } + + It 'does not throw and returns the empty default shape' { + $result = $null + { $script:r = Get-SEBNodeMetrics -Session (New-FakeSession) 3>$null } | Should -Not -Throw + $script:r | Should -Not -BeNullOrEmpty + $script:r.Cpu | Should -BeNullOrEmpty + $script:r.Memory | Should -BeNullOrEmpty + @($script:r.Disks).Count | Should -Be 0 + # CollectedAt is always stamped, even on failure. + $script:r.CollectedAt | Should -BeOfType [datetime] + } + + It 'logs the failure at ERROR via Write-SEBLog' { + Get-SEBNodeMetrics -Session (New-FakeSession) 3>$null | Out-Null + Should -Invoke Write-SEBLog -ModuleName LoadMonitor -ParameterFilter { $Level -eq 'ERROR' } + } + } +} + +Describe 'Test-SEBNodeLoad' { + BeforeAll { + Mock Write-SEBLog {} -ModuleName LoadMonitor + + # Drive the two remote CIM calls deterministically. The function issues two separate + # Invoke-SEBRemoteCommand calls and there is NO parameter that distinguishes them (both pass + # the same -Session; only the inline -ScriptBlock differs), so the only available seam is the + # script-block SOURCE TEXT. That coupling is intentionally explicit and somewhat fragile: if + # Test-SEBNodeLoad ever renames the CIM class it queries (e.g. drops Win32_Processor / + # Win32_OperatingSystem) these arms stop matching and the affected metric reads $null -- + # which would surface as a failing assertion, not a silent pass. To keep that honest we match + # BOTH branches explicitly (rather than treating "anything not memory" as CPU) and THROW on an + # unrecognized block, so a newly-added third remote call can't silently inherit the CPU value. + # - CPU block contains 'Win32_Processor' -> return a numeric load percent. + # - Memory block contains 'Win32_OperatingSystem' -> return Total/Free KB. + # Tests set $script:cpuPercent / $script:totalKb / $script:freeKb before calling. + function Set-NodeLoadMocks { + Mock Get-SEBPlayerCount -ModuleName LoadMonitor { $script:players } + Mock Invoke-SEBRemoteCommand -ModuleName LoadMonitor { + $text = $ScriptBlock.ToString() + if ($text -match 'Win32_OperatingSystem') { + [PSCustomObject]@{ + TotalVisibleMemoryKB = $script:totalKb + FreePhysicalMemoryKB = $script:freeKb + } + } + elseif ($text -match 'Win32_Processor') { + [double]$script:cpuPercent + } + else { + throw "Set-NodeLoadMocks: unrecognized remote scriptblock (neither Win32_Processor nor Win32_OperatingSystem). Update the mock disambiguation to match Test-SEBNodeLoad." + } + } + } + } + + BeforeEach { + # Healthy defaults: 8 GB total, 6 GB free (25% used), 10% CPU, 0 players. + $script:players = 0 + $script:cpuPercent = 10.0 + $script:totalKb = 8 * 1024 * 1024 + $script:freeKb = 6 * 1024 * 1024 + Set-NodeLoadMocks + } + + It 'short-circuits to CanProceed=$true when load_awareness is disabled (no remote calls)' { + $gc = @{ load_awareness = @{ enabled = $false } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeTrue + $r.Reasons | Should -BeNullOrEmpty + Should -Invoke Invoke-SEBRemoteCommand -ModuleName LoadMonitor -Times 0 -Exactly + } + + It 'short-circuits when the load_awareness section is entirely absent' { + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig @{} + $r.CanProceed | Should -BeTrue + } + + It 'proceeds when every metric is comfortably under threshold' { + $gc = @{ load_awareness = @{ enabled = $true; max_cpu_percent = 80; max_memory_percent = 85; max_player_count = 10 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeTrue + @($r.Reasons).Count | Should -Be 0 + $r.CpuPercent | Should -Be 10.0 + $r.PlayerCount | Should -Be 0 + } + + It 'blocks on over-threshold CPU and explains why' { + $script:cpuPercent = 95.0 + $gc = @{ load_awareness = @{ enabled = $true; max_cpu_percent = 80 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeFalse + ($r.Reasons -join ';') | Should -Match 'CPU usage' + $r.CpuPercent | Should -Be 95.0 + } + + It 'treats CPU exactly AT the threshold as acceptable (boundary: > is the trigger, not >=)' { + $script:cpuPercent = 80.0 + $gc = @{ load_awareness = @{ enabled = $true; max_cpu_percent = 80 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeTrue + } + + It 'blocks on over-threshold memory usage' { + # 8 GB total, 0.5 GB free => ~94% used, over an 85% cap. + $script:freeKb = [int](0.5 * 1024 * 1024) + $gc = @{ load_awareness = @{ enabled = $true; max_memory_percent = 85 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeFalse + ($r.Reasons -join ';') | Should -Match 'Memory usage' + } + + It 'treats memory usage exactly AT the threshold as acceptable (boundary: > not >=, and the Round-to-1 edge)' { + # 8 GB total (8192 MB). free 1.2 GB -> round(1258291/1024)=1229 MB -> + # round((8192-1229)/8192*100,1) = 85.0 exactly. 85.0 > 85 is FALSE, so it must proceed. + # This pins BOTH the strict '>' (a '>=' would block here) AND the [math]::Round(...,1) edge. + $script:totalKb = 8 * 1024 * 1024 + $script:freeKb = [int](1.2 * 1024 * 1024) + $gc = @{ load_awareness = @{ enabled = $true; max_memory_percent = 85 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeTrue + ($r.Reasons -join ';') | Should -Not -Match 'Memory usage' + } + + It 'blocks when memory usage is just OVER the threshold (85.1% > 85)' { + # free 1.19 GB -> round(1247805/1024)=1219 MB -> round((8192-1219)/8192*100,1) = 85.1. + # 85.1 > 85 is TRUE, so the just-over case must block (the companion to the AT-threshold case). + $script:totalKb = 8 * 1024 * 1024 + $script:freeKb = [int](1.19 * 1024 * 1024) + $gc = @{ load_awareness = @{ enabled = $true; max_memory_percent = 85 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeFalse + ($r.Reasons -join ';') | Should -Match 'Memory usage' + } + + It 'blocks on over-threshold player count and reports it' { + $script:players = 25 + $gc = @{ load_awareness = @{ enabled = $true; max_player_count = 10 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeFalse + $r.PlayerCount | Should -Be 25 + ($r.Reasons -join ';') | Should -Match 'Player count' + } + + It 'ignores player count when max_player_count is 0 (the "do not gate on players" sentinel)' { + $script:players = 999 + $gc = @{ load_awareness = @{ enabled = $true; max_player_count = 0; max_cpu_percent = 80; max_memory_percent = 85 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeTrue + $r.PlayerCount | Should -Be 999 + } + + It 'accumulates multiple reasons when several metrics are over threshold' { + $script:cpuPercent = 99.0 + $script:players = 50 + $script:freeKb = [int](0.2 * 1024 * 1024) # ~97% memory used + $gc = @{ load_awareness = @{ enabled = $true; max_cpu_percent = 80; max_memory_percent = 85; max_player_count = 10 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeFalse + @($r.Reasons).Count | Should -BeGreaterOrEqual 3 + } + + It 'fails open on CPU/memory: a remote CIM failure does not block the backup' { + # If the node CIM query itself throws, the function logs a warning and leaves that metric + # uncounted -- it must not flip CanProceed to false purely because telemetry was unavailable. + Mock Get-SEBPlayerCount -ModuleName LoadMonitor { 0 } + Mock Invoke-SEBRemoteCommand -ModuleName LoadMonitor { throw 'CIM unavailable' } + $gc = @{ load_awareness = @{ enabled = $true; max_cpu_percent = 80; max_memory_percent = 85 } } + $r = Test-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc 3>$null + $r.CanProceed | Should -BeTrue + } +} + +Describe 'Wait-SEBNodeLoad' { + BeforeAll { + Mock Write-SEBLog {} -ModuleName LoadMonitor + } + + It 'returns CanProceed=$true immediately when load awareness is disabled' { + $gc = @{ load_awareness = @{ enabled = $false } } + $r = Wait-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeTrue + $r.PollCount | Should -Be 0 + } + + It 'on_high_load = "skip" returns CanProceed=$false immediately without polling' { + Mock Test-SEBNodeLoad -ModuleName LoadMonitor { throw 'must not poll when policy is skip' } + Mock Start-Sleep -ModuleName LoadMonitor { throw 'must not sleep when policy is skip' } + $gc = @{ load_awareness = @{ enabled = $true; on_high_load = 'skip' } } + $r = Wait-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeFalse + $r.PollCount | Should -Be 0 + $r.Reason | Should -Match 'skip' + Should -Invoke Test-SEBNodeLoad -ModuleName LoadMonitor -Times 0 -Exactly + Should -Invoke Start-Sleep -ModuleName LoadMonitor -Times 0 -Exactly + } + + It 'defers: waits while busy, then proceeds when load drops (Start-Sleep mocked -- no real wait)' { + # Busy for the first two polls, clear on the third. Start-Sleep is a no-op so the configured + # 30s interval costs nothing; the test runs in milliseconds. + $script:pollNo = 0 + Mock Start-Sleep -ModuleName LoadMonitor {} + Mock Test-SEBNodeLoad -ModuleName LoadMonitor { + $script:pollNo++ + $clear = $script:pollNo -ge 3 + [PSCustomObject]@{ + CanProceed = $clear + PlayerCount = 0 + CpuPercent = if ($clear) { 10 } else { 99 } + AvailableMemoryMB = 5000 + Reasons = if ($clear) { @() } else { @('CPU usage (99%) exceeds threshold (80%)') } + } + } + $gc = @{ load_awareness = @{ enabled = $true; on_high_load = 'defer'; check_interval_seconds = 30; max_backoff_minutes = 60 } } + $r = Wait-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + + $r.CanProceed | Should -BeTrue + $r.PollCount | Should -Be 3 + $r.Reason | Should -Match 'Load dropped' + # It polled three times and slept exactly twice (between the three polls), never for real. + Should -Invoke Test-SEBNodeLoad -ModuleName LoadMonitor -Times 3 -Exactly + Should -Invoke Start-Sleep -ModuleName LoadMonitor -Times 2 -Exactly + } + + It 'proceeds on the very first poll without sleeping when load is already clear' { + Mock Start-Sleep -ModuleName LoadMonitor {} + Mock Test-SEBNodeLoad -ModuleName LoadMonitor { + [PSCustomObject]@{ CanProceed = $true; PlayerCount = 0; CpuPercent = 5; AvailableMemoryMB = 9000; Reasons = @() } + } + $gc = @{ load_awareness = @{ enabled = $true; on_high_load = 'defer'; check_interval_seconds = 30; max_backoff_minutes = 60 } } + $r = Wait-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeTrue + $r.PollCount | Should -Be 1 + Should -Invoke Start-Sleep -ModuleName LoadMonitor -Times 0 -Exactly + } + + It 'honors the legacy defer_* config keys (defer_poll_interval_seconds / defer_wait_minutes)' { + # The shipped names are check_interval_seconds / max_backoff_minutes; the older defer_* names + # must still be accepted so existing operator configs keep working. We assert the loop still + # polls + sleeps under ONLY the legacy keys (proving they were read, not ignored). + $script:legPoll = 0 + Mock Start-Sleep -ModuleName LoadMonitor {} + Mock Test-SEBNodeLoad -ModuleName LoadMonitor { + $script:legPoll++ + [PSCustomObject]@{ CanProceed = ($script:legPoll -ge 2); PlayerCount = 0; CpuPercent = 50; AvailableMemoryMB = 4000; Reasons = @() } + } + $gc = @{ load_awareness = @{ enabled = $true; on_high_load = 'defer'; defer_poll_interval_seconds = 45; defer_wait_minutes = 30 } } + $r = Wait-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc + $r.CanProceed | Should -BeTrue + $r.PollCount | Should -Be 2 + Should -Invoke Start-Sleep -ModuleName LoadMonitor -Times 1 -Exactly + } + + It 'keeps polling + sleeping (does NOT proceed) while the node stays busy -- the wait mechanic' { + # A persistently-busy node must keep deferring, not proceed. The natural wall-clock timeout + # return is bounded by a >=60s real Stopwatch (max_backoff_minutes is integer-minutes and the + # function owns a real Stopwatch we cannot inject), so we do NOT spin to the natural timeout + # in CI. Instead we prove the loop's wait behavior deterministically: keep it busy and let the + # mocked Start-Sleep THROW a sentinel after a few iterations, then assert it polled+slept that + # many times and never returned a proceed verdict. No real sleeping occurs. + $script:sleeps = 0 + Mock Test-SEBNodeLoad -ModuleName LoadMonitor { + [PSCustomObject]@{ CanProceed = $false; PlayerCount = 0; CpuPercent = 99; AvailableMemoryMB = 100; Reasons = @('CPU usage (99%) exceeds threshold (80%)') } + } + Mock Start-Sleep -ModuleName LoadMonitor { + $script:sleeps++ + if ($script:sleeps -ge 3) { throw 'SENTINEL-STOP-WAIT' } + } + $gc = @{ load_awareness = @{ enabled = $true; on_high_load = 'defer'; check_interval_seconds = 30; max_backoff_minutes = 60 } } + { Wait-SEBNodeLoad -Session (New-FakeSession) -InstanceConfig @{} -GlobalConfig $gc } | + Should -Throw -ExpectedMessage '*SENTINEL-STOP-WAIT*' + # It polled and slept (would have waited) rather than proceeding: 3 sleeps means 3 busy polls. + Should -Invoke Start-Sleep -ModuleName LoadMonitor -Times 3 -Exactly + Should -Invoke Test-SEBNodeLoad -ModuleName LoadMonitor -Times 3 -Exactly + } +} diff --git a/Tests/NetworkThrottle/BitsTransfer.Tests.ps1 b/Tests/NetworkThrottle/BitsTransfer.Tests.ps1 new file mode 100644 index 0000000..74a7947 --- /dev/null +++ b/Tests/NetworkThrottle/BitsTransfer.Tests.ps1 @@ -0,0 +1,174 @@ +#Requires -Module Pester + +# Two credential-passthrough tests build a PSCredential from a known plaintext password (the only way +# to construct a SecureString from a literal in-test). No production code uses this pattern; suppress +# the analyzer rule for this fixture file only so the Error-gated build stays clean. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'Test fixture credential; Start-BitsTransfer is mocked so the secret is inert.')] +param() + +# Behavioral tests for the BITS-facing NetworkThrottle public functions (issue #20): +# Start-SEBBitsTransfer / Get-SEBTransferStatus / Stop-SEBTransfer. No real BITS job is ever created. +# +# Seams mocked in NetworkThrottle scope: Test-BITSAvailable, Import-Module (the function imports +# BitsTransfer at run time), and the BitsTransfer cmdlets Start-BitsTransfer / Get-BitsTransfer / +# Remove-BitsTransfer. A fake BITS job object stands in for the real Management.BitsJob. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # Stop-SEBTransfer passes the job to Remove-BitsTransfer -BitsJob, whose parameter is the strict + # type [Management.BitsJob[]]; a PSCustomObject would not bind (its mock would be skipped). So the + # double is a real (uninitialized) BitsJob -- which has no public ctor -- with the handful of + # members the functions read shadowed via Add-Member. It binds to -BitsJob AND reads back our + # values; Get-BitsTransfer (mocked) just hands this object back, so no real BITS state is touched. + function New-FakeBitsJob { + param( + [guid]$JobId = [guid]'11111111-2222-3333-4444-555555555555', + [string]$JobState = 'Transferred', + [long]$BytesTransferred = 1048576, + [long]$BytesTotal = 10485760 + ) + $j = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject( + [Microsoft.BackgroundIntelligentTransfer.Management.BitsJob]) + $j | Add-Member -NotePropertyName JobId -NotePropertyValue $JobId -Force + $j | Add-Member -NotePropertyName JobState -NotePropertyValue $JobState -Force + $j | Add-Member -NotePropertyName BytesTransferred -NotePropertyValue $BytesTransferred -Force + $j | Add-Member -NotePropertyName BytesTotal -NotePropertyValue $BytesTotal -Force + $j + } +} + +Describe 'Start-SEBBitsTransfer' { + BeforeEach { + Mock Import-Module -ModuleName NetworkThrottle {} -ParameterFilter { $Name -eq 'BitsTransfer' } + } + + It 'throws a clear error when BITS is unavailable (no transfer attempted)' { + Mock Test-BITSAvailable -ModuleName NetworkThrottle { $false } + Mock Start-BitsTransfer -ModuleName NetworkThrottle { throw 'must not start a transfer when BITS is unavailable' } + { Start-SEBBitsTransfer -Source 'C:\a.7z' -Destination '\\nas\a.7z' } | + Should -Throw -ExpectedMessage '*BITS*not available*' + Should -Invoke Start-BitsTransfer -ModuleName NetworkThrottle -Times 0 -Exactly + } + + It 'starts a synchronous transfer with the default Low priority' { + Mock Test-BITSAvailable -ModuleName NetworkThrottle { $true } + Mock Start-BitsTransfer -ModuleName NetworkThrottle {} + Start-SEBBitsTransfer -Source 'C:\a.7z' -Destination '\\nas\a.7z' + Should -Invoke Start-BitsTransfer -ModuleName NetworkThrottle -Times 1 -Exactly -ParameterFilter { + $Source -eq 'C:\a.7z' -and $Destination -eq '\\nas\a.7z' -and $Priority -eq 'Low' -and -not $Asynchronous + } + } + + It 'returns the BITS job object when -Asynchronous is specified' { + Mock Test-BITSAvailable -ModuleName NetworkThrottle { $true } + Mock Start-BitsTransfer -ModuleName NetworkThrottle { New-FakeBitsJob } + $job = Start-SEBBitsTransfer -Source 'C:\a.7z' -Destination '\\nas\a.7z' -Asynchronous + $job | Should -Not -BeNullOrEmpty + $job.JobId | Should -Be ([guid]'11111111-2222-3333-4444-555555555555') + Should -Invoke Start-BitsTransfer -ModuleName NetworkThrottle -Times 1 -Exactly -ParameterFilter { $Asynchronous -eq $true } + } + + It 'honors a non-default Priority and passes a Credential through' { + Mock Test-BITSAvailable -ModuleName NetworkThrottle { $true } + Mock Start-BitsTransfer -ModuleName NetworkThrottle {} + $sec = ConvertTo-SecureString 'p' -AsPlainText -Force + $cred = [System.Management.Automation.PSCredential]::new('u', $sec) + Start-SEBBitsTransfer -Source 'C:\a.7z' -Destination '\\nas\a.7z' -Priority Normal -Credential $cred + Should -Invoke Start-BitsTransfer -ModuleName NetworkThrottle -Times 1 -Exactly -ParameterFilter { + $Priority -eq 'Normal' -and $null -ne $Credential -and $Credential.UserName -eq 'u' + } + } + + It 'rethrows when the underlying BITS transfer fails' { + Mock Test-BITSAvailable -ModuleName NetworkThrottle { $true } + Mock Start-BitsTransfer -ModuleName NetworkThrottle { throw 'network path not found' } + { Start-SEBBitsTransfer -Source 'C:\a.7z' -Destination '\\nas\a.7z' 2>$null } | Should -Throw + } +} + +Describe 'Get-SEBTransferStatus' { + BeforeEach { + Mock Import-Module -ModuleName NetworkThrottle {} -ParameterFilter { $Name -eq 'BitsTransfer' } + } + + It 'computes PercentComplete from bytes transferred/total and returns the documented shape' { + Mock Get-BitsTransfer -ModuleName NetworkThrottle { New-FakeBitsJob -BytesTransferred 1048576 -BytesTotal 10485760 -JobState 'Transferring' } + $s = Get-SEBTransferStatus -JobId '11111111-2222-3333-4444-555555555555' + $s.State | Should -Be 'Transferring' + $s.BytesTransferred | Should -Be 1048576 + $s.BytesTotal | Should -Be 10485760 + $s.PercentComplete | Should -Be 10.0 + $s.PSObject.Properties.Name | Should -Contain 'JobId' + } + + It 'reports 0% when BytesTotal is 0 (avoids divide-by-zero)' { + Mock Get-BitsTransfer -ModuleName NetworkThrottle { New-FakeBitsJob -BytesTransferred 0 -BytesTotal 0 -JobState 'Connecting' } + (Get-SEBTransferStatus -JobId '99999999-0000-0000-0000-000000000000').PercentComplete | Should -Be 0.0 + } + + It 'accepts a BITS job OBJECT (resolves via its .JobId) as well as a string id' { + Mock Get-BitsTransfer -ModuleName NetworkThrottle { + # -JobId is [guid[]]; normalize to the single id for the assertion. + $script:capturedJobId = [string](@($JobId)[0]) + New-FakeBitsJob + } + $job = New-FakeBitsJob + Get-SEBTransferStatus -JobId $job | Out-Null + # When handed an object, the function passes its .JobId (a guid) to Get-BitsTransfer. + $script:capturedJobId | Should -Be '11111111-2222-3333-4444-555555555555' + } + + It 'returns $null (does not throw) when the job cannot be found' { + Mock Get-BitsTransfer -ModuleName NetworkThrottle { throw 'No BITS jobs matched' } + $result = 'sentinel' + # The function reports the failure as a NON-terminating error and returns $null. Pass + # -ErrorAction SilentlyContinue so the contract holds even when the caller's preference is + # 'Stop' (otherwise the function's own Write-Error would terminate under a Stop caller). + { $script:gs = Get-SEBTransferStatus -JobId '00000000-1111-2222-3333-444444444444' -ErrorAction SilentlyContinue } | Should -Not -Throw + $script:gs | Should -BeNullOrEmpty + } +} + +Describe 'Stop-SEBTransfer' { + BeforeEach { + Mock Import-Module -ModuleName NetworkThrottle {} -ParameterFilter { $Name -eq 'BitsTransfer' } + } + + It 'removes (cancels) the BITS job when it exists' { + Mock Get-BitsTransfer -ModuleName NetworkThrottle { New-FakeBitsJob -JobState 'Transferring' } + Mock Remove-BitsTransfer -ModuleName NetworkThrottle {} + Stop-SEBTransfer -JobId '11111111-2222-3333-4444-555555555555' + Should -Invoke Remove-BitsTransfer -ModuleName NetworkThrottle -Times 1 -Exactly + } + + It 'resolves a string job id when querying the job' { + Mock Remove-BitsTransfer -ModuleName NetworkThrottle {} + Mock Get-BitsTransfer -ModuleName NetworkThrottle { + $script:stopQueryId = [string](@($JobId)[0]) + New-FakeBitsJob + } + Stop-SEBTransfer -JobId 'deadbeef-0000-0000-0000-000000000000' + $script:stopQueryId | Should -Be 'deadbeef-0000-0000-0000-000000000000' + } + + It 'warns and does nothing when the job is not found (no Remove call, no throw)' { + Mock Get-BitsTransfer -ModuleName NetworkThrottle { $null } + Mock Remove-BitsTransfer -ModuleName NetworkThrottle { throw 'must not remove a non-existent job' } + # Capture the warning stream directly (3>&1) -- robust across the module boundary. + $out = $null + { $script:out = Stop-SEBTransfer -JobId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' 3>&1 } | Should -Not -Throw + Should -Invoke Remove-BitsTransfer -ModuleName NetworkThrottle -Times 0 -Exactly + (@($script:out) -join "`n") | Should -Match 'not found' + } + + It 'surfaces an error (does not throw) if removing the job fails' { + Mock Get-BitsTransfer -ModuleName NetworkThrottle { New-FakeBitsJob } + Mock Remove-BitsTransfer -ModuleName NetworkThrottle { throw 'access denied' } + # Reported as a non-terminating error; -ErrorAction SilentlyContinue keeps it non-fatal even + # under a 'Stop' caller, matching the "never blocks the pipeline" contract. + { Stop-SEBTransfer -JobId '11111111-2222-3333-4444-555555555555' -ErrorAction SilentlyContinue } | Should -Not -Throw + } +} diff --git a/Tests/NetworkThrottle/Copy-SEBThrottled.Tests.ps1 b/Tests/NetworkThrottle/Copy-SEBThrottled.Tests.ps1 new file mode 100644 index 0000000..447b8b5 --- /dev/null +++ b/Tests/NetworkThrottle/Copy-SEBThrottled.Tests.ps1 @@ -0,0 +1,222 @@ +#Requires -Module Pester + +# One credential-passthrough test builds a PSCredential from a known plaintext password (the only way +# to construct a SecureString from a literal in-test). No production code uses this pattern; suppress +# the analyzer rule for this fixture file only so the Error-gated build stays clean. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'Test fixture credential; the underlying transfer is mocked so the secret is inert.')] +param() + +# Behavioral tests for Copy-SEBThrottled (issue #20). The existing NetworkThrottle suite only unit- +# tested the private ConvertTo-RobocopyIpg; this exercises the public copy dispatcher's +# STRATEGY SELECTION and result shape without moving real bytes over a real network: +# +# Strategy 1 (BITS) : -UseBITS + BITS available -> delegates to Start-SEBBitsTransfer (Low prio). +# Strategy 2 (Robocopy) : robocopy present -> /IPG throttling, explicit ms wins over derived Mbps, +# and the no-throttle robocopy path omits /IPG. +# Strategy 3 (Copy-Item) : neither BITS nor robocopy -> warns and copies unthrottled. +# +# Seams mocked in NetworkThrottle scope: Test-BITSAvailable, Start-SEBBitsTransfer, the external +# robocopy executable (Pester CAN mock it; the mock sets $LASTEXITCODE), Get-Command (to hide +# robocopy for the fallback case), and Copy-Item. Real temp files/dirs are used only so the source +# size probe and directory-vs-leaf detection behave naturally. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # A real temp source DIRECTORY (with a file for the size calc) + a real destination directory. + # A directory source keeps robocopy on its directory path and skips the leaf post-verify/move + # block, so no real file ever has to land for the mocked-robocopy assertions. + $script:work = Join-Path ([System.IO.Path]::GetTempPath()) ("SEBThrottle_" + [guid]::NewGuid().ToString('n')) + $script:srcDir = Join-Path $script:work 'src' + $script:dstDir = Join-Path $script:work 'dst' + New-Item -ItemType Directory -Path $script:srcDir -Force | Out-Null + New-Item -ItemType Directory -Path $script:dstDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:srcDir 'payload.bin') -Value ('x' * 4096) -NoNewline + + # A real temp source FILE for BITS/Copy-Item leaf tests. + $script:srcFile = Join-Path $script:work 'archive.7z' + Set-Content -LiteralPath $script:srcFile -Value ('y' * 2048) -NoNewline +} + +AfterAll { + if (Test-Path -LiteralPath $script:work) { Remove-Item -LiteralPath $script:work -Recurse -Force -ErrorAction SilentlyContinue } +} + +Describe 'Copy-SEBThrottled :: Strategy 1 (BITS)' { + BeforeEach { + Mock Test-BITSAvailable -ModuleName NetworkThrottle { $true } + Mock Start-SEBBitsTransfer -ModuleName NetworkThrottle {} + } + + It 'uses BITS (Priority Low) when -UseBITS is set and BITS is available' { + $r = Copy-SEBThrottled -Source $script:srcFile -Destination $script:dstDir -UseBITS + $r.Method | Should -Be 'BITS' + Should -Invoke Start-SEBBitsTransfer -ModuleName NetworkThrottle -Times 1 -Exactly -ParameterFilter { + $Source -eq $script:srcFile -and $Priority -eq 'Low' + } + } + + It 'passes a -Credential through to the BITS transfer' { + $sec = ConvertTo-SecureString 'p' -AsPlainText -Force + $cred = [System.Management.Automation.PSCredential]::new('u', $sec) + Copy-SEBThrottled -Source $script:srcFile -Destination $script:dstDir -UseBITS -Credential $cred | Out-Null + Should -Invoke Start-SEBBitsTransfer -ModuleName NetworkThrottle -Times 1 -Exactly -ParameterFilter { + $null -ne $Credential -and $Credential.UserName -eq 'u' + } + } + + It 'reports the real source size and a populated result object' { + $r = Copy-SEBThrottled -Source $script:srcFile -Destination $script:dstDir -UseBITS + $r.SizeBytes | Should -Be 2048 + $r.Source | Should -Be $script:srcFile + $r.Destination | Should -Be $script:dstDir + $r.PSObject.Properties.Name | Should -Contain 'AverageMbps' + $r.PSObject.Properties.Name | Should -Contain 'DurationSeconds' + } + + It 'falls through to another strategy when -UseBITS is set but BITS is unavailable' { + Mock Test-BITSAvailable -ModuleName NetworkThrottle { $false } + Mock Start-SEBBitsTransfer -ModuleName NetworkThrottle { throw 'BITS must not be used when unavailable' } + Mock robocopy -ModuleName NetworkThrottle { $global:LASTEXITCODE = 0; '' } + $r = Copy-SEBThrottled -Source $script:srcDir -Destination $script:dstDir -UseBITS + $r.Method | Should -Not -Be 'BITS' + Should -Invoke Start-SEBBitsTransfer -ModuleName NetworkThrottle -Times 0 -Exactly + } +} + +Describe 'Copy-SEBThrottled :: Strategy 2 (Robocopy /IPG throttling)' { + BeforeEach { + # Not using BITS in these tests; robocopy is the path. Mock the exe so nothing copies for real. + Mock Test-BITSAvailable -ModuleName NetworkThrottle { $false } + $script:roboArgs = $null + Mock robocopy -ModuleName NetworkThrottle { + $script:roboArgs = $args + $global:LASTEXITCODE = 0 + '' + } + } + + It 'derives the /IPG gap from MaxBandwidthMbps (10 Mbps -> /IPG:52)' { + $r = Copy-SEBThrottled -Source $script:srcDir -Destination $script:dstDir -MaxBandwidthMbps 10 + $r.Method | Should -Be 'Robocopy' + # The first two positional args ARE the copy itself; without pinning them the /IPG / /E checks + # could pass while robocopy copied the wrong source to the wrong destination. + $script:roboArgs[0] | Should -Be $script:srcDir + $script:roboArgs[1] | Should -Be $script:dstDir + ($script:roboArgs -join ' ') | Should -Match '/IPG:52' + ($script:roboArgs -join ' ') | Should -Match '/E' # recursive directory copy + } + + It 'prefers an explicit RobocopyIpgMs over the value derived from MaxBandwidthMbps' { + Copy-SEBThrottled -Source $script:srcDir -Destination $script:dstDir -MaxBandwidthMbps 10 -RobocopyIpgMs 200 | Out-Null + ($script:roboArgs -join ' ') | Should -Match '/IPG:200' + ($script:roboArgs -join ' ') | Should -Not -Match '/IPG:52' + } + + It 'omits /IPG entirely when no throttle is configured (robocopy still used for reliability)' { + $r = Copy-SEBThrottled -Source $script:srcDir -Destination $script:dstDir + $r.Method | Should -Be 'Robocopy' + # Still the real copy: source/destination must be present and correct on the no-throttle branch. + $script:roboArgs[0] | Should -Be $script:srcDir + $script:roboArgs[1] | Should -Be $script:dstDir + ($script:roboArgs -join ' ') | Should -Not -Match '/IPG:' + } + + It 'throws when robocopy reports a fatal exit code (>= 8)' { + Mock robocopy -ModuleName NetworkThrottle { $global:LASTEXITCODE = 8; 'ERROR 8 (0x00000008) copy failed' } + { Copy-SEBThrottled -Source $script:srcDir -Destination $script:dstDir -MaxBandwidthMbps 10 2>$null } | + Should -Throw -ExpectedMessage '*Robocopy failed*' + } + + It 'treats robocopy exit codes 0-7 as success (e.g. 1 = files copied)' { + Mock robocopy -ModuleName NetworkThrottle { $global:LASTEXITCODE = 1; '' } + { Copy-SEBThrottled -Source $script:srcDir -Destination $script:dstDir -MaxBandwidthMbps 10 } | Should -Not -Throw + } + + It 'for a single-file source, robocopy gets the (srcDir, destDir, filename) triplet -- not a file as arg2' { + # Robocopy's 2nd arg must be a DIRECTORY; a leaf source is split into srcDir + filename so the + # file is selected by pattern. Pin all three positionals. The mock "lands" the file robocopy + # would have produced (\) so the function's post-copy verify passes + # naturally without a real network copy. + $script:roboArgs = $null + Mock robocopy -ModuleName NetworkThrottle { + $script:roboArgs = $args + Copy-Item -LiteralPath (Join-Path $args[0] $args[2]) -Destination (Join-Path $args[1] $args[2]) -Force + $global:LASTEXITCODE = 0 + '' + } + $expectedSrcDir = [System.IO.Path]::GetDirectoryName($script:srcFile) + $expectedFile = [System.IO.Path]::GetFileName($script:srcFile) + $r = Copy-SEBThrottled -Source $script:srcFile -Destination $script:dstDir -MaxBandwidthMbps 10 + $r.Method | Should -Be 'Robocopy' + $script:roboArgs[0] | Should -Be $expectedSrcDir # source DIRECTORY + $script:roboArgs[1] | Should -Be $script:dstDir # destination DIRECTORY + $script:roboArgs[2] | Should -Be $expectedFile # filename pattern (arg2 is NOT a path) + ($script:roboArgs -join ' ') | Should -Match '/IPG:52' + } + + It 'silently ignores -Credential on the robocopy path (robocopy cannot accept a PSCredential)' { + # HONESTY GUARD: robocopy is a native exe invoked as `& robocopy @args`; the function never + # references $Credential on either robocopy branch. So a -Credential is DROPPED with no warning + # -- assert exactly that (no false impression the credential is honored): the copy still runs, + # no warning is emitted, and the credential never appears anywhere in the robocopy arg vector. + $script:roboArgs = $null + Mock robocopy -ModuleName NetworkThrottle { $script:roboArgs = $args; $global:LASTEXITCODE = 0; '' } + $sec = ConvertTo-SecureString 'p' -AsPlainText -Force + $cred = [System.Management.Automation.PSCredential]::new('robo-user', $sec) + $warnings = @() + $r = Copy-SEBThrottled -Source $script:srcDir -Destination $script:dstDir -MaxBandwidthMbps 10 ` + -Credential $cred -WarningVariable warnings -WarningAction SilentlyContinue + $r.Method | Should -Be 'Robocopy' + $warnings | Should -BeNullOrEmpty # not warned: silently ignored + # Pester's -Match is case-insensitive; the credential (username or the literal '/credential' + # switch robocopy has no concept of) must appear nowhere in the arg vector. + ($script:roboArgs -join ' ') | Should -Not -Match 'robo-user' # credential never forwarded + ($script:roboArgs -join ' ') | Should -Not -Match 'credential' + } +} + +Describe 'Copy-SEBThrottled :: Strategy 3 (Copy-Item fallback, no throttling)' { + BeforeEach { + Mock Test-BITSAvailable -ModuleName NetworkThrottle { $false } + # Hide robocopy so the dispatcher reaches the Copy-Item fallback. + Mock Get-Command -ModuleName NetworkThrottle { $null } -ParameterFilter { $Name -eq 'robocopy' } + Mock Copy-Item -ModuleName NetworkThrottle {} + } + + It 'falls back to Copy-Item and reports Method=CopyItem' { + $r = Copy-SEBThrottled -Source $script:srcFile -Destination $script:dstDir -WarningAction SilentlyContinue + $r.Method | Should -Be 'CopyItem' + Should -Invoke Copy-Item -ModuleName NetworkThrottle -Times 1 -Exactly + } + + It 'warns that bandwidth limits could not be enforced on the fallback path' { + $warnings = @() + Copy-SEBThrottled -Source $script:srcFile -Destination $script:dstDir -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null + ($warnings -join "`n") | Should -Match 'no bandwidth throttling' + } + + It 'adds -Recurse for a directory source on the fallback path' { + Copy-SEBThrottled -Source $script:srcDir -Destination $script:dstDir -WarningAction SilentlyContinue | Out-Null + Should -Invoke Copy-Item -ModuleName NetworkThrottle -Times 1 -Exactly -ParameterFilter { $Recurse -eq $true } + } + + It 'does NOT add -Recurse for a single-file source on the fallback path' { + Copy-SEBThrottled -Source $script:srcFile -Destination $script:dstDir -WarningAction SilentlyContinue | Out-Null + Should -Invoke Copy-Item -ModuleName NetworkThrottle -Times 1 -Exactly -ParameterFilter { -not $Recurse } + } + + It 'FORWARDS -Credential to Copy-Item (unlike robocopy, this path can honor it for some providers)' { + # Counterpart to the robocopy "silently ignored" guard: the Copy-Item branch DOES add + # $copyParams['Credential'], so assert the real credential is forwarded (not dropped). This + # pins the contrast so neither path's credential behavior can silently regress unnoticed. + $sec = ConvertTo-SecureString 'p' -AsPlainText -Force + $cred = [System.Management.Automation.PSCredential]::new('copy-user', $sec) + Copy-SEBThrottled -Source $script:srcFile -Destination $script:dstDir -Credential $cred -WarningAction SilentlyContinue | Out-Null + Should -Invoke Copy-Item -ModuleName NetworkThrottle -Times 1 -Exactly -ParameterFilter { + $null -ne $Credential -and $Credential.UserName -eq 'copy-user' + } + } +} diff --git a/Tests/NotificationManager/NotificationManager.Tests.ps1 b/Tests/NotificationManager/NotificationManager.Tests.ps1 new file mode 100644 index 0000000..fb58a66 --- /dev/null +++ b/Tests/NotificationManager/NotificationManager.Tests.ps1 @@ -0,0 +1,328 @@ +#Requires -Module Pester + +# Behavioral tests for the NotificationManager module (issue #20). Previously only the +# function-existence contract ran; this suite exercises the Discord webhook payload construction, +# level/type gating, the backup/restore summary builders, and -- CRITICALLY (per CLAUDE.md) -- that +# a transport failure NEVER throws or blocks the calling backup/restore pipeline. +# +# Infrastructure boundary mocked: the transport is Invoke-WebRequest (the module posts the JSON embed +# to the Discord webhook). All HTTP is mocked in NotificationManager scope; no network is touched. +# The 429 rate-limit path is exercised with a fake HttpResponseException-shaped exception plus a +# mocked Start-Sleep, so the retry/backoff runs without any real waiting or real HTTP. +# +# Mocks use -ModuleName NotificationManager because the functions run in that nested module scope +# when imported via the root SEBackup.psd1. New-SEBDiscordEmbed (private) is reached via InModuleScope. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # A minimal stand-in for the PS7 HttpResponseException shape the 429 handler inspects: + # $_.Exception.Response.StatusCode (int-castable) + # $_.Exception.Response.Headers.TryGetValues('Retry-After', [ref]$out) + class FakeRespHeaders { + [object]$RetryAfter = $null + [bool] TryGetValues([string]$name, [ref]$values) { + if ($name -eq 'Retry-After') { $values.Value = @('2'); return $true } + return $false + } + } + class FakeResponse { + [int]$StatusCode + [FakeRespHeaders]$Headers = [FakeRespHeaders]::new() + } + class FakeHttpException : System.Exception { + [FakeResponse]$Response + FakeHttpException([string]$message, [int]$code) : base($message) { + $this.Response = [FakeResponse]::new() + $this.Response.StatusCode = $code + } + } + # Expose to test bodies (classes are discovery-scoped; stash factories on script scope). + function New-FakeHttpException { param([int]$Code) [FakeHttpException]::new("HTTP $Code", $Code) } + + # A standard "everything on" notifications config. + function New-NotifConfig { + param([hashtable]$Override = @{}) + $base = @{ + enabled = $true + webhook_url = 'https://discord.example/api/webhooks/123/abc' + on_success = $true + on_warning = $true + on_failure = $true + on_restore = $true + failure_mention = '<@&999>' + } + foreach ($k in $Override.Keys) { $base[$k] = $Override[$k] } + @{ notifications = $base } + } +} + +Describe 'New-SEBDiscordEmbed (embed construction)' { + It 'maps each notification Type to its documented color' { + InModuleScope NotificationManager { + (New-SEBDiscordEmbed -Type Success -Title t -Message m).embeds[0].color | Should -Be 0x2ECC71 + (New-SEBDiscordEmbed -Type Warning -Title t -Message m).embeds[0].color | Should -Be 0xF1C40F + (New-SEBDiscordEmbed -Type Failure -Title t -Message m).embeds[0].color | Should -Be 0xE74C3C + (New-SEBDiscordEmbed -Type Restore -Title t -Message m).embeds[0].color | Should -Be 0x3498DB + (New-SEBDiscordEmbed -Type Info -Title t -Message m).embeds[0].color | Should -Be 0x95A5A6 + } + } + + It 'places title/description on the embed and wraps it in an embeds array' { + InModuleScope NotificationManager { + $p = New-SEBDiscordEmbed -Type Info -Title 'My Title' -Message 'My Body' + @($p.embeds).Count | Should -Be 1 + $p.embeds[0].title | Should -Be 'My Title' + $p.embeds[0].description | Should -Be 'My Body' + $p.embeds[0].timestamp | Should -Match '^\d{4}-\d{2}-\d{2}T' + } + } + + It 'renders a Fields hashtable as inline embed fields' { + InModuleScope NotificationManager { + $p = New-SEBDiscordEmbed -Type Success -Title t -Message m -Fields @{ Duration = '3m'; Size = '2 GB' } + $names = @($p.embeds[0].fields | ForEach-Object { $_.name }) + $names | Should -Contain 'Duration' + $names | Should -Contain 'Size' + ($p.embeds[0].fields | Where-Object name -eq 'Duration').value | Should -Be '3m' + # Assert inline POSITIVELY: every field's inline must be $true. The old + # '... | Should -Not -Contain $false' passed vacuously if the inline key were dropped + # (each element would be $null, never $false). Here a dropped/non-true inline FAILS. + @($p.embeds[0].fields | ForEach-Object { $_.inline }) | Should -Be @($true, $true) + ($p.embeds[0].fields | Where-Object { $_.inline -ne $true }) | Should -BeNullOrEmpty + } + } + + It 'promotes a Mention to top-level content (so Discord pings the role/user)' { + InModuleScope NotificationManager { + $p = New-SEBDiscordEmbed -Type Failure -Title t -Message m -Mention '<@&123>' + $p.content | Should -Be '<@&123>' + } + } + + It 'omits content entirely when no mention is supplied' { + InModuleScope NotificationManager { + $p = New-SEBDiscordEmbed -Type Info -Title t -Message m + $p.ContainsKey('content') | Should -BeFalse + } + } +} + +Describe 'Send-SEBNotification (core sender)' { + BeforeEach { + Mock Invoke-WebRequest -ModuleName NotificationManager { [PSCustomObject]@{ StatusCode = 204 } } + Mock Start-Sleep -ModuleName NotificationManager {} + } + + It 'posts the embed JSON to the configured webhook URL' { + Send-SEBNotification -Type Success -Title 'Done' -Message 'ok' -GlobalConfig (New-NotifConfig) + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq 'https://discord.example/api/webhooks/123/abc' -and + $ContentType -eq 'application/json' + } + } + + It 'serializes a well-formed Discord payload (embed with the right color)' { + $script:capturedBody = $null + Mock Invoke-WebRequest -ModuleName NotificationManager { + $script:capturedBody = $Body + [PSCustomObject]@{ StatusCode = 204 } + } + Send-SEBNotification -Type Failure -Title 'Boom' -Message 'VSS error' -Fields @{ Code = '8' } -GlobalConfig (New-NotifConfig) + $payload = $script:capturedBody | ConvertFrom-Json + $payload.embeds[0].title | Should -Be 'Boom' + $payload.embeds[0].color | Should -Be 0xE74C3C + ($payload.embeds[0].fields | Where-Object name -eq 'Code').value | Should -Be '8' + } + + It 'prepends the failure_mention as content ONLY for Failure notifications' { + $script:bodies = [System.Collections.Generic.List[string]]::new() + Mock Invoke-WebRequest -ModuleName NotificationManager { + $script:bodies.Add($Body); [PSCustomObject]@{ StatusCode = 204 } + } + Send-SEBNotification -Type Failure -Title f -Message m -GlobalConfig (New-NotifConfig) + Send-SEBNotification -Type Success -Title s -Message m -GlobalConfig (New-NotifConfig) + $fail = $script:bodies[0] | ConvertFrom-Json + $succ = $script:bodies[1] | ConvertFrom-Json + $fail.content | Should -Be '<@&999>' + # Success must NOT carry a mention even though failure_mention is configured. + ($succ.PSObject.Properties.Name -contains 'content') | Should -BeFalse + } + + It 'skips (no HTTP) when the notifications section is missing' { + Send-SEBNotification -Type Info -Title t -Message m -GlobalConfig @{} 3>$null + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 0 -Exactly + } + + It 'skips (no HTTP) when notifications.enabled is false' { + Send-SEBNotification -Type Info -Title t -Message m -GlobalConfig (New-NotifConfig @{ enabled = $false }) + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 0 -Exactly + } + + It 'skips (no HTTP) when webhook_url is blank' { + Send-SEBNotification -Type Info -Title t -Message m -GlobalConfig (New-NotifConfig @{ webhook_url = '' }) 3>$null + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 0 -Exactly + } + + It 'NEVER throws when the transport fails (CLAUDE.md: notifications must not block backups)' { + Mock Invoke-WebRequest -ModuleName NotificationManager { throw 'connection refused' } + { Send-SEBNotification -Type Failure -Title t -Message m -GlobalConfig (New-NotifConfig) 3>$null } | + Should -Not -Throw + } + + It 'retries on HTTP 429 honoring Retry-After, then succeeds (Start-Sleep mocked -- no real wait)' { + $script:attempt = 0 + Mock Invoke-WebRequest -ModuleName NotificationManager { + $script:attempt++ + if ($script:attempt -eq 1) { throw (New-FakeHttpException -Code 429) } + [PSCustomObject]@{ StatusCode = 204 } + } + { Send-SEBNotification -Type Info -Title t -Message m -GlobalConfig (New-NotifConfig) 3>$null } | Should -Not -Throw + # First attempt 429, second attempt 204 => exactly two posts and exactly one backoff sleep. + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 2 -Exactly + Should -Invoke Start-Sleep -ModuleName NotificationManager -Times 1 -Exactly -ParameterFilter { $Seconds -eq 2 } + } + + It 'does not loop forever: a persistent 429 stops after the retry budget and never throws' { + Mock Invoke-WebRequest -ModuleName NotificationManager { throw (New-FakeHttpException -Code 429) } + { Send-SEBNotification -Type Info -Title t -Message m -GlobalConfig (New-NotifConfig) 3>$null } | Should -Not -Throw + # maxRetries = 3 -> three POST attempts total. + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 3 -Exactly + } + + It 'does not retry a non-429 HTTP error (single attempt, no throw)' { + Mock Invoke-WebRequest -ModuleName NotificationManager { throw (New-FakeHttpException -Code 500) } + { Send-SEBNotification -Type Info -Title t -Message m -GlobalConfig (New-NotifConfig) 3>$null } | Should -Not -Throw + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 1 -Exactly + } +} + +Describe 'Send-SEBBackupNotification (summary builder + gating)' { + BeforeEach { + # Intercept the core sender so we assert the derived Type/Title/Fields without HTTP. + Mock Send-SEBNotification -ModuleName NotificationManager {} + } + + It 'sends a Success notification for a clean backup result' { + $result = [PSCustomObject]@{ Type = 'Full'; Duration = 135.0; ArchiveSize = 2GB; FilesChanged = 12; Success = $true; ErrorMessage = $null } + Send-SEBBackupNotification -InstanceName 'PvPArena' -BackupResult $result -GlobalConfig (New-NotifConfig) + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 1 -Exactly -ParameterFilter { + $Type -eq 'Success' -and $Title -like '*PvPArena*' + } + } + + It 'derives Failure (not Success) when the result reports Success=$false' { + $result = [PSCustomObject]@{ Type = 'Incremental'; Duration = 5.0; ArchiveSize = 0; FilesChanged = 0; Success = $false; ErrorMessage = 'VSS timed out' } + Send-SEBBackupNotification -InstanceName 'Creative01' -BackupResult $result -GlobalConfig (New-NotifConfig) + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 1 -Exactly -ParameterFilter { + $Type -eq 'Failure' -and $Message -like '*VSS timed out*' + } + } + + It 'derives Warning when the backup succeeded but carries an ErrorMessage' { + $result = [PSCustomObject]@{ Type = 'Full'; Duration = 60.0; ArchiveSize = 1MB; FilesChanged = 3; Success = $true; ErrorMessage = 'one file skipped' } + Send-SEBBackupNotification -InstanceName 'Srv' -BackupResult $result -GlobalConfig (New-NotifConfig) + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 1 -Exactly -ParameterFilter { $Type -eq 'Warning' } + } + + It 'does NOT send when the matching on_* flag is disabled (Success suppressed)' { + $result = [PSCustomObject]@{ Type = 'Full'; Duration = 1; ArchiveSize = 1; FilesChanged = 0; Success = $true } + Send-SEBBackupNotification -InstanceName 'Srv' -BackupResult $result -GlobalConfig (New-NotifConfig @{ on_success = $false }) + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 0 -Exactly + } + + It 'a failure is still suppressed when on_failure is false (per-type gating, not just enabled)' { + $result = [PSCustomObject]@{ Type = 'Full'; Duration = 1; ArchiveSize = 0; FilesChanged = 0; Success = $false; ErrorMessage = 'x' } + Send-SEBBackupNotification -InstanceName 'Srv' -BackupResult $result -GlobalConfig (New-NotifConfig @{ on_failure = $false }) + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 0 -Exactly + } + + It 'formats duration (m/s) and a human-readable size, and accepts the canonical result shape' { + # Canonical Invoke-SEBBackup shape: BackupType / ArchiveSizeBytes / FileCount. + $result = [PSCustomObject]@{ BackupType = 'Full'; Duration = [timespan]::FromSeconds(125); ArchiveSizeBytes = [long](2.5 * 1GB); FileCount = 7; Success = $true } + Send-SEBBackupNotification -InstanceName 'Srv' -BackupResult $result -GlobalConfig (New-NotifConfig) + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 1 -Exactly -ParameterFilter { + $Fields['Type'] -eq 'Full' -and + $Fields['Duration'] -eq '2m 5s' -and + $Fields['Size'] -match 'GB' -and + $Fields['Files Changed'] -eq '7' + } + } + + It 'never throws even if the core sender blows up (defense in depth)' { + Mock Send-SEBNotification -ModuleName NotificationManager { throw 'unexpected' } + $result = [PSCustomObject]@{ Type = 'Full'; Duration = 1; ArchiveSize = 1; FilesChanged = 0; Success = $true } + { Send-SEBBackupNotification -InstanceName 'Srv' -BackupResult $result -GlobalConfig (New-NotifConfig) 3>$null } | + Should -Not -Throw + } +} + +Describe 'Send-SEBRestoreNotification' { + BeforeEach { + Mock Send-SEBNotification -ModuleName NotificationManager {} + } + + It 'sends a Restore-typed notification with the instance/restore-point/initiator fields' { + Send-SEBRestoreNotification -InstanceName 'PvPArena' -RestorePoint '2026-02-27_Full' -InitiatedBy 'Admin:Jane' -GlobalConfig (New-NotifConfig) + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 1 -Exactly -ParameterFilter { + $Type -eq 'Restore' -and + $Title -like '*PvPArena*' -and + $Fields['Restore Point'] -eq '2026-02-27_Full' -and + $Fields['Initiated By'] -eq 'Admin:Jane' + } + } + + It 'defaults Initiated By to "CLI" when not supplied' { + Send-SEBRestoreNotification -InstanceName 'Srv' -RestorePoint 'rp1' -GlobalConfig (New-NotifConfig) + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 1 -Exactly -ParameterFilter { + $Fields['Initiated By'] -eq 'CLI' + } + } + + It 'sends by default when on_restore is absent (absent means "default on")' { + $cfg = New-NotifConfig + $cfg.notifications.Remove('on_restore') + Send-SEBRestoreNotification -InstanceName 'Srv' -RestorePoint 'rp1' -GlobalConfig $cfg + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 1 -Exactly + } + + It 'is suppressed only when on_restore is explicitly the boolean $false' { + Send-SEBRestoreNotification -InstanceName 'Srv' -RestorePoint 'rp1' -GlobalConfig (New-NotifConfig @{ on_restore = $false }) 3>$null + Should -Invoke Send-SEBNotification -ModuleName NotificationManager -Times 0 -Exactly + } + + It 'never throws if the core sender fails' { + Mock Send-SEBNotification -ModuleName NotificationManager { throw 'boom' } + { Send-SEBRestoreNotification -InstanceName 'Srv' -RestorePoint 'rp1' -GlobalConfig (New-NotifConfig) 3>$null } | + Should -Not -Throw + } +} + +Describe 'Test-SEBNotificationConfig (diagnostic check)' { + It 'returns $true on a 2xx response from a well-formed config' { + Mock Invoke-WebRequest -ModuleName NotificationManager { [PSCustomObject]@{ StatusCode = 204 } } + Test-SEBNotificationConfig -GlobalConfig (New-NotifConfig) | Should -BeTrue + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 1 -Exactly + } + + It 'returns $false when the notifications section is missing (no HTTP)' { + Mock Invoke-WebRequest -ModuleName NotificationManager { [PSCustomObject]@{ StatusCode = 204 } } + Test-SEBNotificationConfig -GlobalConfig @{} 3>$null | Should -BeFalse + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 0 -Exactly + } + + It 'returns $false when webhook_url is blank (no HTTP)' { + Mock Invoke-WebRequest -ModuleName NotificationManager { [PSCustomObject]@{ StatusCode = 204 } } + Test-SEBNotificationConfig -GlobalConfig (New-NotifConfig @{ webhook_url = ' ' }) 3>$null | Should -BeFalse + Should -Invoke Invoke-WebRequest -ModuleName NotificationManager -Times 0 -Exactly + } + + It 'returns $false (does not throw) when the transport fails' { + Mock Invoke-WebRequest -ModuleName NotificationManager { throw 'unreachable' } + $result = $null + { $script:tc = Test-SEBNotificationConfig -GlobalConfig (New-NotifConfig) 3>$null } | Should -Not -Throw + $script:tc | Should -BeFalse + } +} diff --git a/Tests/SchedulerManager/SchedulerManager.Tests.ps1 b/Tests/SchedulerManager/SchedulerManager.Tests.ps1 new file mode 100644 index 0000000..db379e4 --- /dev/null +++ b/Tests/SchedulerManager/SchedulerManager.Tests.ps1 @@ -0,0 +1,344 @@ +#Requires -Module Pester + +# Behavioral tests for the SchedulerManager module (issue #20). Previously only the function-existence +# contract ran; this suite exercises the Task Scheduler action/trigger/principal construction, the +# S4U "run whether logged on or not" principal, the legacy-credential warning, the status mapping, and +# the update paths -- WITHOUT writing anything to the real Windows Task Scheduler. +# +# Infrastructure boundary mocked (in SchedulerManager scope): the ScheduledTasks cmdlets +# New-ScheduledTaskTrigger / New-ScheduledTaskAction / New-ScheduledTaskPrincipal / +# New-ScheduledTaskSettingsSet / Register-ScheduledTask / Get-ScheduledTask / +# Get-ScheduledTaskInfo / Set-ScheduledTask / Enable-ScheduledTask / Disable-ScheduledTask / +# Unregister-ScheduledTask, plus ConvertFrom-Toml and Get-ChildItem (the legacy-cred scan). +# Construction is asserted by capturing the arguments bound to the New-ScheduledTask* mocks. +# +# CI runs on windows-latest, so these cmdlets exist and are mockable; no real task is ever created. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # The ScheduledTasks cmdlets type their parameters as [CimInstance]/[CimInstance[]] and + # Get-ScheduledTaskInfo's pipeline -InputObject is a MANDATORY [CimInstance]. A PSCustomObject + # therefore can't bind (the mock body would be skipped). So our doubles are REAL client-only + # CimInstances (which bind to every typed param) carrying the handful of properties the function + # reads as ETS (Add-Member) members. This keeps the boundary fully mocked while letting the typed + # binding succeed. + # The ScheduledTasks cmdlets type their parameters as [CimInstance]/[CimInstance[]] AND the binder + # matches the specific PSTypeName (e.g. ...#MSFT_TaskTrigger); Get-ScheduledTaskInfo's pipeline + # -InputObject is a MANDATORY [CimInstance]#MSFT_ScheduledTask. A PSCustomObject cannot bind (the + # mock body would be skipped). So our doubles are REAL client-only CimInstances of the CORRECT + # MSFT class (so they bind), created in the taskscheduler namespace. + # + # We only Add-Member properties that are NOT real adapter members of that class (e.g. 'Kind', + # 'Triggers', 'TaskName'); CIM-native members like 'State' are set via -Property at creation, and + # real adapter members we don't need (principal UserId/LogonType/RunLevel) are left at their + # defaults -- the construction itself is asserted on the arguments bound to the New-ScheduledTask* + # mocks, not on the objects they return. + $script:TaskNs = 'root/microsoft/windows/taskscheduler' + function New-FakeCim { + param([string]$Class, [hashtable]$CimProps = @{}, [hashtable]$Extra = @{}) + $ci = New-CimInstance -ClassName $Class -Namespace $script:TaskNs -ClientOnly -Property $CimProps + foreach ($k in $Extra.Keys) { $ci | Add-Member -NotePropertyName $k -NotePropertyValue $Extra[$k] -Force } + $ci + } + + # A fabricated MSFT_ScheduledTask with controllable State + Triggers (a real CimInstance so it + # pipes into Get-ScheduledTaskInfo). State is CIM-native; Triggers/TaskName are instance extensions. + # + # IMPORTANT (state mapping): the CIM-native State is the typed ScheduledState enum, whose + # .ToString() ALREADY yields the friendly name ('Ready', 'Running', ...). That makes this double + # USELESS for verifying Get-SEBScheduleStatus's own integer-code switch: a broken (or absent) + # switch that just did $task.State.ToString() would still produce 'Ready' and the test would pass + # vacuously. State-mapping tests therefore use New-FakeTaskRawState below instead. + function New-FakeTask { + param( + [int]$State = 3, # 3 = Ready + [string]$Interval = 'PT6H', # ISO-8601 repetition interval + [string]$StartBoundary = '2026-01-01T02:00:00' + ) + New-FakeCim -Class 'MSFT_ScheduledTask' -CimProps @{ State = [uint16]$State } -Extra @{ + TaskName = 'SEBackup-Scheduled' + Triggers = @( + [PSCustomObject]@{ + StartBoundary = $StartBoundary + Repetition = [PSCustomObject]@{ Interval = $Interval } + } + ) + } + } + + # A task double whose State is a RAW integer (.ToString() -> '3', '4', ...), so the only way to + # obtain the friendly string is Get-SEBScheduleStatus's switch actually mapping the code. We use a + # PSCustomObject (not a CimInstance) on purpose: a CimInstance's native State member is the typed + # enum and shadows any raw value we try to attach (an ETS override reads back as $null), and it + # would stringify to the friendly name -- defeating the test. Get-SEBScheduleStatus never pipes the + # task into a strongly-typed cmdlet except Get-ScheduledTaskInfo, which these tests mock, so a + # PSCustomObject binds fine. This makes a broken switch arm (or a no-switch .ToString()) FAIL. + function New-FakeTaskRawState { + param( + [int]$State = 3, + [string]$Interval = 'PT6H', + [string]$StartBoundary = '2026-01-01T02:00:00' + ) + [PSCustomObject]@{ + TaskName = 'SEBackup-Scheduled' + State = [int]$State # raw code; switch must transform it to a friendly string + Triggers = @( + [PSCustomObject]@{ + StartBoundary = $StartBoundary + Repetition = [PSCustomObject]@{ Interval = $Interval } + } + ) + } + } + + # Mocks shared by the register/update suites that build a task. Each builder returns a real + # client-only CimInstance of the right class so it binds to Register/Set-ScheduledTask. + function Set-SchedulerBuildMocks { + Mock New-ScheduledTaskTrigger -ModuleName SchedulerManager { New-FakeCim -Class 'MSFT_TaskTrigger' } + Mock New-ScheduledTaskAction -ModuleName SchedulerManager { New-FakeCim -Class 'MSFT_TaskAction' } + Mock New-ScheduledTaskPrincipal -ModuleName SchedulerManager { New-FakeCim -Class 'MSFT_TaskPrincipal' } + Mock New-ScheduledTaskSettingsSet -ModuleName SchedulerManager { New-FakeCim -Class 'MSFT_TaskSettings' } + Mock Register-ScheduledTask -ModuleName SchedulerManager { New-FakeCim -Class 'MSFT_ScheduledTask' -Extra @{ TaskName = $TaskName } } + Mock Unregister-ScheduledTask -ModuleName SchedulerManager {} + # No legacy .cred.xml files by default. + Mock Get-ChildItem -ModuleName SchedulerManager { @() } -ParameterFilter { $Filter -eq '*.cred.xml' } + } +} + +Describe 'Register-SEBScheduledTask' { + BeforeEach { + Set-SchedulerBuildMocks + # Fresh registration by default: no existing task. + Mock Get-ScheduledTask -ModuleName SchedulerManager { $null } + $script:gc = @{ schedule = @{ interval_hours = 4; start_time = '03:30' } } + } + + It 'builds a repeating Once trigger from interval_hours/start_time' { + Register-SEBScheduledTask -GlobalConfig $script:gc 3>$null | Out-Null + Should -Invoke New-ScheduledTaskTrigger -ModuleName SchedulerManager -Times 1 -Exactly -ParameterFilter { + $Once -eq $true -and $null -ne $RepetitionInterval -and $RepetitionInterval.TotalHours -eq 4 + } + } + + It 'builds an action that runs pwsh.exe with the backup script and -All' { + Register-SEBScheduledTask -GlobalConfig $script:gc 3>$null | Out-Null + Should -Invoke New-ScheduledTaskAction -ModuleName SchedulerManager -Times 1 -Exactly -ParameterFilter { + $Execute -eq 'pwsh.exe' -and + $Argument -like '*Invoke-Backup.ps1*' -and + $Argument -like '*-All*' -and + $Argument -like '*-NoProfile*' + } + } + + It 'builds an S4U, highest-privilege, current-user principal (run whether logged on or not)' { + $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + Register-SEBScheduledTask -GlobalConfig $script:gc 3>$null | Out-Null + Should -Invoke New-ScheduledTaskPrincipal -ModuleName SchedulerManager -Times 1 -Exactly -ParameterFilter { + $LogonType -eq 'S4U' -and $RunLevel -eq 'Highest' -and $UserId -eq $me + } + } + + It 'registers the task with the assembled trigger/action/principal/settings' { + Register-SEBScheduledTask -TaskName 'MyTask' -GlobalConfig $script:gc 3>$null | Out-Null + # Each forwarded piece must be the corresponding builder's CimInstance output (asserted by + # its specific MSFT PSTypeName) -- proving the assembled objects were the ones passed on. + Should -Invoke Register-ScheduledTask -ModuleName SchedulerManager -Times 1 -Exactly -ParameterFilter { + $TaskName -eq 'MyTask' -and + (@($Trigger)[0].PSTypeNames -contains 'Microsoft.Management.Infrastructure.CimInstance#MSFT_TaskTrigger') -and + (@($Action)[0].PSTypeNames -contains 'Microsoft.Management.Infrastructure.CimInstance#MSFT_TaskAction') -and + (@($Principal)[0].PSTypeNames -contains 'Microsoft.Management.Infrastructure.CimInstance#MSFT_TaskPrincipal') -and + (@($Settings)[0].PSTypeNames -contains 'Microsoft.Management.Infrastructure.CimInstance#MSFT_TaskSettings') + } + } + + It 'uses documented defaults (6h / 02:00) when the schedule keys are absent' { + Register-SEBScheduledTask -GlobalConfig @{ schedule = @{} } 3>$null | Out-Null + Should -Invoke New-ScheduledTaskTrigger -ModuleName SchedulerManager -Times 1 -Exactly -ParameterFilter { + $RepetitionInterval.TotalHours -eq 6 + } + } + + It 'throws on an invalid start_time format' { + { Register-SEBScheduledTask -GlobalConfig @{ schedule = @{ start_time = '25:99' } } 3>$null } | + Should -Throw -ExpectedMessage '*start_time*' + } + + It 'throws when the [schedule] section is missing entirely' { + { Register-SEBScheduledTask -GlobalConfig @{} 3>$null } | Should -Throw -ExpectedMessage '*schedule*' + } + + It 'overwrites an existing task (unregister first, then register)' { + Mock Get-ScheduledTask -ModuleName SchedulerManager { [PSCustomObject]@{ TaskName = 'SEBackup-Scheduled' } } + Register-SEBScheduledTask -GlobalConfig $script:gc 3>$null | Out-Null + Should -Invoke Unregister-ScheduledTask -ModuleName SchedulerManager -Times 1 -Exactly + Should -Invoke Register-ScheduledTask -ModuleName SchedulerManager -Times 1 -Exactly + } + + It 'warns (and names the nodes) when legacy .cred.xml credentials are present' { + Mock Get-ChildItem -ModuleName SchedulerManager { + @( + [PSCustomObject]@{ BaseName = 'node1.cred' } + [PSCustomObject]@{ BaseName = 'node2.cred' } + ) + } -ParameterFilter { $Filter -eq '*.cred.xml' } + $warnings = @() + Register-SEBScheduledTask -GlobalConfig $script:gc -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null + ($warnings -join "`n") | Should -Match 'legacy' + ($warnings -join "`n") | Should -Match 'node1' + ($warnings -join "`n") | Should -Match 'node2' + } + + It 'honors -WhatIf: it does NOT call Register-ScheduledTask' { + Register-SEBScheduledTask -GlobalConfig $script:gc -WhatIf 3>$null | Out-Null + Should -Invoke Register-ScheduledTask -ModuleName SchedulerManager -Times 0 -Exactly + } + + It 'loads the config from global.toml when -GlobalConfig is not supplied' { + # Cover the file-load branch without depending on the real TOML on disk. + Mock Test-Path -ModuleName SchedulerManager { $true } -ParameterFilter { $Path -like '*global.toml' } + Mock Get-Content -ModuleName SchedulerManager { 'stub-toml' } -ParameterFilter { $Path -like '*global.toml' } + Mock ConvertFrom-Toml -ModuleName SchedulerManager { @{ schedule = @{ interval_hours = 8; start_time = '01:00' } } } + Register-SEBScheduledTask 3>$null | Out-Null + Should -Invoke ConvertFrom-Toml -ModuleName SchedulerManager -Times 1 -Exactly + Should -Invoke New-ScheduledTaskTrigger -ModuleName SchedulerManager -Times 1 -Exactly -ParameterFilter { $RepetitionInterval.TotalHours -eq 8 } + } +} + +Describe 'Unregister-SEBScheduledTask' { + It 'warns and does nothing when the task does not exist' { + Mock Get-ScheduledTask -ModuleName SchedulerManager { $null } + Mock Unregister-ScheduledTask -ModuleName SchedulerManager {} + Unregister-SEBScheduledTask -TaskName 'Missing' -Force 3>$null + Should -Invoke Unregister-ScheduledTask -ModuleName SchedulerManager -Times 0 -Exactly + } + + It 'removes an existing task when -Force is supplied (no confirmation prompt)' { + Mock Get-ScheduledTask -ModuleName SchedulerManager { [PSCustomObject]@{ TaskName = 'SEBackup-Scheduled' } } + Mock Unregister-ScheduledTask -ModuleName SchedulerManager {} + Mock Read-Host -ModuleName SchedulerManager { throw 'must not prompt when -Force is given' } + Unregister-SEBScheduledTask -Force 3>$null + Should -Invoke Unregister-ScheduledTask -ModuleName SchedulerManager -Times 1 -Exactly -ParameterFilter { $TaskName -eq 'SEBackup-Scheduled' } + Should -Invoke Read-Host -ModuleName SchedulerManager -Times 0 -Exactly + } + + It 'honors -WhatIf: it does not remove the task' { + Mock Get-ScheduledTask -ModuleName SchedulerManager { [PSCustomObject]@{ TaskName = 'SEBackup-Scheduled' } } + Mock Unregister-ScheduledTask -ModuleName SchedulerManager {} + Unregister-SEBScheduledTask -Force -WhatIf 3>$null + Should -Invoke Unregister-ScheduledTask -ModuleName SchedulerManager -Times 0 -Exactly + } +} + +Describe 'Get-SEBScheduleStatus' { + It 'returns Exists=$false with null fields when the task is not found' { + Mock Get-ScheduledTask -ModuleName SchedulerManager { $null } + $s = Get-SEBScheduleStatus -TaskName 'Nope' 3>$null + $s.Exists | Should -BeFalse + $s.TaskName | Should -Be 'Nope' + $s.State | Should -BeNullOrEmpty + $s.NextRunTime | Should -BeNullOrEmpty + $s.TriggerInterval | Should -BeNullOrEmpty + } + + It 'maps an existing Ready task with parsed run times and trigger interval' { + # This case proves the run-time / interval PARSING and pipes into Get-ScheduledTaskInfo, which + # requires a real CimInstance (its -InputObject is a mandatory [CimInstance]; a PSCustomObject + # cannot bind, so the GSTI mock would be skipped and the run times would come back $null). The + # SWITCH itself is exercised separately, against raw integer codes, in the next test. + Mock Get-ScheduledTask -ModuleName SchedulerManager { New-FakeTask -State 3 -Interval 'PT6H' } + Mock Get-ScheduledTaskInfo -ModuleName SchedulerManager { + [PSCustomObject]@{ + NextRunTime = [datetime]'2026-02-28T02:00:00' + LastRunTime = [datetime]'2026-02-27T20:00:00' + LastTaskResult = 0 + } + } + $s = Get-SEBScheduleStatus 3>$null + $s.Exists | Should -BeTrue + $s.State | Should -Be 'Ready' + $s.LastResult | Should -Be 0 + $s.NextRunTime | Should -Be ([datetime]'2026-02-28T02:00:00') + $s.TriggerInterval | Should -Be ([System.Xml.XmlConvert]::ToTimeSpan('PT6H')) + } + + It 'maps every documented state CODE to its friendly string via the switch (0/1/2/3/4 + default)' { + # The REAL exercise of Get-SEBScheduleStatus's integer-code switch. New-FakeTaskRawState carries + # State as a RAW integer (.ToString() -> '3', '4', ...), so the friendly string can ONLY come + # from the switch mapping the code -- not from the State already stringifying to it (which is + # what the CIM-native enum does, vacuously passing a broken/absent switch). Each assertion also + # checks the result is NOT the raw code, so a broken arm (or a no-switch .ToString()) FAILS. + # A single mock reads $script:stateCode, so there is no per-iteration closure capture to get + # wrong. We do not assert run times here (a PSCustomObject can't bind Get-ScheduledTaskInfo); + # the previous test owns that. + Mock Get-ScheduledTask -ModuleName SchedulerManager { New-FakeTaskRawState -State $script:stateCode } + + $cases = [ordered]@{ 0 = 'Unknown'; 1 = 'Disabled'; 2 = 'Queued'; 3 = 'Ready'; 4 = 'Running' } + foreach ($code in $cases.Keys) { + $script:stateCode = [int]$code + $mapped = (Get-SEBScheduleStatus 3>$null).State + $mapped | Should -Be $cases[$code] + $mapped | Should -Not -Be "$code" + } + + # The 'default' arm: an unmapped code (e.g. 7) falls through to $task.State.ToString() => '7'. + $script:stateCode = 7 + (Get-SEBScheduleStatus 3>$null).State | Should -Be '7' + } +} + +Describe 'Update-SEBSchedule' { + BeforeEach { + # A real CimInstance trigger so it binds to Set-ScheduledTask -Trigger [CimInstance[]]. + Mock New-ScheduledTaskTrigger -ModuleName SchedulerManager { New-FakeCim -Class 'MSFT_TaskTrigger' } + Mock Set-ScheduledTask -ModuleName SchedulerManager { New-FakeCim -Class 'MSFT_ScheduledTask' } + Mock Enable-ScheduledTask -ModuleName SchedulerManager {} + Mock Disable-ScheduledTask -ModuleName SchedulerManager {} + # The function fetches the task at the start (must exist) and again at the end (return value). + Mock Get-ScheduledTask -ModuleName SchedulerManager { New-FakeTask } + } + + It 'throws when the task does not exist' { + Mock Get-ScheduledTask -ModuleName SchedulerManager { $null } + { Update-SEBSchedule -IntervalHours 4 3>$null } | Should -Throw -ExpectedMessage '*does not exist*' + } + + It 'updates the trigger when -IntervalHours is given' { + Update-SEBSchedule -IntervalHours 8 3>$null | Out-Null + Should -Invoke New-ScheduledTaskTrigger -ModuleName SchedulerManager -Times 1 -Exactly -ParameterFilter { $RepetitionInterval.TotalHours -eq 8 } + Should -Invoke Set-ScheduledTask -ModuleName SchedulerManager -Times 1 -Exactly + } + + It 'rejects an invalid -StartTime via the parameter pattern (no Set-ScheduledTask call)' { + # ValidatePattern '^\d{2}:\d{2}$' rejects '3:30' at binding time. + { Update-SEBSchedule -StartTime '3:30' 3>$null } | Should -Throw + Should -Invoke Set-ScheduledTask -ModuleName SchedulerManager -Times 0 -Exactly + } + + It 'enables the task when -Enabled $true' { + Update-SEBSchedule -Enabled $true 3>$null | Out-Null + Should -Invoke Enable-ScheduledTask -ModuleName SchedulerManager -Times 1 -Exactly + Should -Invoke Disable-ScheduledTask -ModuleName SchedulerManager -Times 0 -Exactly + } + + It 'disables the task when -Enabled $false' { + Update-SEBSchedule -Enabled $false 3>$null | Out-Null + Should -Invoke Disable-ScheduledTask -ModuleName SchedulerManager -Times 1 -Exactly + Should -Invoke Enable-ScheduledTask -ModuleName SchedulerManager -Times 0 -Exactly + } + + It 'makes no changes (no trigger rebuild, no enable/disable) when no optional params are given' { + Update-SEBSchedule 3>$null | Out-Null + Should -Invoke New-ScheduledTaskTrigger -ModuleName SchedulerManager -Times 0 -Exactly + Should -Invoke Set-ScheduledTask -ModuleName SchedulerManager -Times 0 -Exactly + Should -Invoke Enable-ScheduledTask -ModuleName SchedulerManager -Times 0 -Exactly + Should -Invoke Disable-ScheduledTask -ModuleName SchedulerManager -Times 0 -Exactly + } + + It 'returns the (re-fetched) task object' { + $r = Update-SEBSchedule -IntervalHours 4 3>$null + $r | Should -Not -BeNullOrEmpty + $r.TaskName | Should -Be 'SEBackup-Scheduled' + } +} diff --git a/Tests/_TestHelpers/Test-Doubles.ps1 b/Tests/_TestHelpers/Test-Doubles.ps1 new file mode 100644 index 0000000..0d58e15 --- /dev/null +++ b/Tests/_TestHelpers/Test-Doubles.ps1 @@ -0,0 +1,58 @@ +# Shared Pester test doubles for the SEBackup suite. +# +# This file is NOT a *.Tests.ps1, so Pester's default discovery skips it; test files dot-source it +# from their BeforeAll, e.g.: +# . "$PSScriptRoot/../_TestHelpers/Test-Doubles.ps1" +# +# It exists to de-duplicate the hand-rolled PSSession double that several suites previously each +# defined inline (the FormatterServices.GetUninitializedObject([PSSession]) trick). + +function New-FakeSession { + <# + .SYNOPSIS + Builds a type-satisfying System.Management.Automation.Runspaces.PSSession that touches no + transport, for binding to [PSSession]-typed parameters under mock. + + .DESCRIPTION + PSSession has no public constructor, so we materialize an UNINITIALIZED instance via + FormatterServices. It binds to any [PSSession] parameter while opening no runspace and + touching no WinRM. Every consumer of the returned object is expected to be mocked. + + When -Name / -Target are supplied, the corresponding adapted .Name / .ComputerName + ScriptProperties are added so functions that read those (e.g. to parse a cache node from a + "SEBackup-" session name) see the intended values. Omit them for the bare double. + + .PARAMETER Name + Optional value exposed as the session's .Name property. + + .PARAMETER Target + Optional value exposed as the session's .ComputerName property. Named -Target (not + -ComputerName) so PSScriptAnalyzer's PSAvoidUsingComputerNameHardcoded rule does not flag a + literal bound to a -ComputerName parameter on this test helper. + + .OUTPUTS + System.Management.Automation.Runspaces.PSSession (uninitialized). + #> + [CmdletBinding()] + [OutputType([System.Management.Automation.Runspaces.PSSession])] + param( + [string]$Name, + [string]$Target + ) + + $session = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject( + [System.Management.Automation.Runspaces.PSSession]) + + # Capture the value via a closure (not string-interpolation into [scriptblock]::Create), so a + # single quote in the name can't break the getter and there's no string-parsing/injection risk. + if ($PSBoundParameters.ContainsKey('Name')) { + $session | Add-Member -Force -MemberType ScriptProperty -Name Name ` + -Value { $Name }.GetNewClosure() + } + if ($PSBoundParameters.ContainsKey('Target')) { + $session | Add-Member -Force -MemberType ScriptProperty -Name ComputerName ` + -Value { $Target }.GetNewClosure() + } + + return $session +}