Test/Get-ScheduledTaskAudit.Tests.ps1

using namespace System
using namespace System.IO
using namespace System.Security.Cryptography
using namespace System.Collections.Immutable

Set-StrictMode -Version Latest

# Evaluated at discovery time so -Skip resolves while Pester builds the tree.
$IsElevatedHost = $false
if ($IsWindows) {
    $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
    $IsElevatedHost = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

BeforeAll {
    $moduleRoot = Split-Path -Path $PSScriptRoot -Parent
    Import-Module (Join-Path -Path $moduleRoot -ChildPath 'PSSecurity.psd1') -Force
}
AfterAll {
    Remove-Module PSSecurity -Force -ErrorAction SilentlyContinue
}

#### <h2 style="color: #DCA657;">Get-ScheduledTaskAudit</h2>
####
#### Skipped off Windows. The task scheduler CIM classes do not exist there.
####
Describe 'Get-ScheduledTaskAudit' -Skip:(-not $IsWindows) {
    BeforeAll {
        # Warnings are captured rather than displayed, so an unelevated suite run
        # does not spray the elevation notice across every case.
        $script:audit = @(Get-ScheduledTaskAudit -WarningAction SilentlyContinue)
        $script:raw = @(Get-ScheduledTask -TaskPath '\*' -ErrorAction SilentlyContinue)
    }

    ####
    #### <b style="color: #D2A8FF;">Cases</b>
    ####
    #### - Regression: `$task.Actions.Execute` throws on COM handler actions, and the
    #### throw kills the whole record. Row parity with the raw enumeration is what
    #### catches tasks being dropped silently rather than erroring.
    It 'Returns one row per scheduled task, dropping none' {
        $script:audit.Count | Should -Be $script:raw.Count
        $script:audit.Count | Should -BeGreaterThan 0
    }

    #### - The documented field set is present on every row.
    It 'Projects the documented fields' {
        $expected = @(
            'TaskName', 'TaskPath', 'State', 'Author', 'RunAs', 'PrincipalKind',
            'LogonType', 'RunLevel', 'ActionKind', 'Runs',
            'LastRunTime', 'LastTaskResult', 'LastResultHex', 'LastResultKind', 'NextRunTime'
        )
        $names = @($script:audit[0].PSObject.Properties.Name)
        foreach ($field in $expected) {
            $names | Should -Contain $field
        }
    }

    #### - Regression: a principal is either a UserId or a GroupId. Reading only
    #### UserId left roughly a third of rows with no identity, and said nothing.
    It 'Never leaves RunAs blank' {
        @($script:audit | Where-Object { [string]::IsNullOrWhiteSpace($_.RunAs) }).Count | Should -Be 0
    }

    #### - And names which of the two produced it.
    It 'Reports PrincipalKind as User or Group' {
        @($script:audit | Where-Object { $_.PrincipalKind -notin 'User', 'Group' }).Count | Should -Be 0
    }

    #### - Regression: COM handler tasks are the ones that used to disappear, so
    #### their presence is the positive proof the switch handles both classes.
    It 'Includes COM handler tasks alongside exec tasks' {
        @($script:audit | Where-Object { $_.ActionKind -match 'ComHandler' }).Count | Should -BeGreaterThan 0
        @($script:audit | Where-Object { $_.ActionKind -match 'Exec' }).Count | Should -BeGreaterThan 0
    }

    #### - A COM action records its class id rather than an empty command line.
    It 'Records a ClassId for COM handler actions' {
        $com = @($script:audit | Where-Object { $_.ActionKind -eq 'ComHandler' })
        $com[0].Runs | Should -Match '^COM:'
    }

    #### - Every result classifies, and the hex form is what makes a code readable.
    It 'Classifies every result and renders it as hex' {
        @($script:audit | Where-Object { $_.LastResultKind -notin 'Success', 'Informational', 'Failure' }).Count | Should -Be 0
        @($script:audit | Where-Object { $_.LastResultHex -notmatch '^0x[0-9A-F]{8}$' }).Count | Should -Be 0
    }

    #### - Regression: `LastTaskResult -ne 0` is mostly false positives. The
    #### scheduler's own informational range must not classify as failure.
    It 'Treats the 0x000413xx informational range as not a failure' {
        $informational = @($script:audit | Where-Object { $_.LastResultHex -like '0x000413*' })
        @($informational | Where-Object { $_.LastResultKind -eq 'Failure' }).Count | Should -Be 0
    }

    #### - A failure is exactly a set HRESULT severity bit, nothing else.
    It 'Classifies a result as Failure only when the severity bit is set' {
        foreach ($row in @($script:audit | Where-Object { $_.LastResultKind -eq 'Failure' })) {
            ([uint32]$row.LastTaskResult -band 0x80000000) | Should -Not -Be 0
        }
    }
}
####
#### ---
####

#### <h2 style="color: #DCA657;">Get-ScheduledTaskAudit elevated</h2>
####
Describe 'Get-ScheduledTaskAudit elevated' -Skip:(-not ($IsWindows -and $IsElevatedHost)) {
    # One enumeration serves both cases, as in the unelevated block below.
    BeforeAll {
        $script:elevatedAudit = @(Get-ScheduledTaskAudit -WarningVariable elevationWarning -WarningAction SilentlyContinue)
        $script:elevatedWarning = @($elevationWarning)
    }

    ####
    #### <b style="color: #D2A8FF;">Cases</b>
    ####
    #### - An elevated session sees the whole machine, so it raises no warning.
    It 'Raises no elevation warning' {
        $script:elevatedWarning.Count | Should -Be 0
    }

    #### - SYSTEM context tasks are the ones an unelevated session cannot see, and
    #### they are the bulk of what elevation buys.
    It 'Sees SYSTEM context tasks' {
        @($script:elevatedAudit | Where-Object { $_.RunAs -eq 'SYSTEM' }).Count | Should -BeGreaterThan 0
    }
}
####
#### ---
####

#### <h2 style="color: #DCA657;">Get-ScheduledTaskAudit unelevated</h2>
####
Describe 'Get-ScheduledTaskAudit unelevated' -Skip:(-not ($IsWindows -and -not $IsElevatedHost)) {
    # One enumeration serves both cases. Each call walks every scheduled task over
    # CIM, and the audit and its warning come out of the same call anyway.
    BeforeAll {
        $script:unelevatedAudit = @(Get-ScheduledTaskAudit -WarningVariable elevationWarning -WarningAction SilentlyContinue)
        $script:unelevatedWarning = @($elevationWarning)
    }

    ####
    #### <b style="color: #D2A8FF;">Cases</b>
    ####
    #### - The partial view must announce itself. Silence would present two thirds
    #### of the machine as the whole of it, weighted toward the privileged end.
    It 'Warns that enumeration is incomplete' {
        $script:unelevatedWarning.Count | Should -BeGreaterThan 0
        [string]$script:unelevatedWarning[0] | Should -Match 'incomplete'
    }

    #### - It still returns what it can see. A partial audit beats no audit.
    It 'Still returns the tasks it can see' {
        $script:unelevatedAudit.Count | Should -BeGreaterThan 0
    }
}
####
#### ---
####