Tests/Get-MsecIntuneScriptResult.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecIntuneScriptResult. The behaviours that matter are the ones a partial
# implementation would get wrong and still look plausible: five different Intune script
# features projecting to one row shape without conflating what 'Output' means, a
# remediation's pre/post outputs both surviving, and a mistyped -Name failing loudly rather
# than returning nothing (which would read as "that script has never run").
#
# The five collections here ARE the complete set Intune exposes under /deviceManagement -
# verified against Graph's own $metadata, not from memory. Three of them share one
# deviceRunStates type, which is why the projection branches on StateShape rather than on
# -Source.

BeforeAll {
    $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1'
    Import-Module $modulePath -Force -ErrorAction Stop

    $script:TestThumbBytes = [byte[]](1..20)

    # Passed as TEXT and rebuilt inside InModuleScope: a scriptblock stays bound to the
    # session state it was written in and could not resolve Mock's private targets there.
    #
    # EVERY collection is mocked. There is no catch-all, so an unmocked endpoint reaches the
    # real Invoke-RestMethod and fails as 401 - which is how the -Source All tests caught
    # the three new collections when they were added.
    $script:MockText = @'
Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
    [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
}

# ---- the five script collections ----
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceHealthScripts$' } -MockWith {
    [pscustomobject]@{ value = @(
        [pscustomobject]@{ id = 'rem-1'; displayName = 'Check-BitLocker' }
        [pscustomobject]@{ id = 'rem-2'; displayName = 'Never-Ran' }
    ) }
}
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagementScripts$' } -MockWith {
    [pscustomobject]@{ value = @([pscustomobject]@{ id = 'winps-1'; displayName = 'Set-RegistryTweak' }) }
}
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceShellScripts$' } -MockWith {
    [pscustomobject]@{ value = @([pscustomobject]@{ id = 'macsh-1'; displayName = 'Install-Rosetta' }) }
}
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceCustomAttributeShellScripts$' } -MockWith {
    [pscustomobject]@{ value = @([pscustomobject]@{ id = 'attr-1'; displayName = 'FileVault-Status' }) }
}
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceComplianceScripts$' } -MockWith {
    [pscustomobject]@{ value = @([pscustomobject]@{ id = 'comp-1'; displayName = 'Check-TpmVersion' }) }
}

# ---- run states: deviceHealthScriptDeviceState (two outputs) ----
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceHealthScripts/rem-1/deviceRunStates' } -MockWith {
    [pscustomobject]@{ value = @(
        [pscustomobject]@{
            id = 'rs-1'
            detectionState = 'success'; remediationState = 'success'
            preRemediationDetectionScriptOutput = 'Suspended'
            postRemediationDetectionScriptOutput = 'On'
            lastStateUpdateDateTime = '2026-08-01T10:00:00Z'
            managedDevice = [pscustomobject]@{ id = 'dev-1'; deviceName = 'LAPTOP-A1'; userPrincipalName = 'a@x.com' }
        }
        [pscustomobject]@{
            id = 'rs-2'
            detectionState = 'fail'; remediationState = 'remediationFailed'
            preRemediationDetectionScriptOutput = 'Off'
            postRemediationDetectionScriptOutput = $null
            remediationScriptError = 'Access denied enabling BitLocker'
            lastStateUpdateDateTime = '2026-08-02T10:00:00Z'
            managedDevice = [pscustomobject]@{ id = 'dev-2'; deviceName = 'LAPTOP-B2'; userPrincipalName = 'b@x.com' }
        }
    ) }
}
# Assigned but nothing has reported.
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceHealthScripts/rem-2/deviceRunStates' } -MockWith {
    [pscustomobject]@{ value = @() }
}

# ---- run states: deviceManagementScriptDeviceState, shared by THREE features ----
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagementScripts/winps-1/deviceRunStates' } -MockWith {
    [pscustomobject]@{ value = @(
        [pscustomobject]@{
            id = 'rs-5'; runState = 'success'; resultMessage = 'Registry value set'
            lastStateUpdateDateTime = '2026-08-04T10:00:00Z'
            managedDevice = [pscustomobject]@{ id = 'dev-5'; deviceName = 'DESKTOP-C3'; userPrincipalName = 'e@x.com' }
        }
    ) }
}
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceShellScripts/macsh-1/deviceRunStates' } -MockWith {
    [pscustomobject]@{ value = @(
        [pscustomobject]@{
            id = 'rs-6'; runState = 'fail'; resultMessage = $null
            errorCode = 127; errorDescription = 'softwareupdate: command failed'
            lastStateUpdateDateTime = '2026-08-05T10:00:00Z'
            managedDevice = [pscustomobject]@{ id = 'dev-6'; deviceName = 'MacBook-Air-2'; userPrincipalName = 'f@x.com' }
        }
    ) }
}
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceCustomAttributeShellScripts/attr-1/deviceRunStates' } -MockWith {
    [pscustomobject]@{ value = @(
        [pscustomobject]@{
            id = 'rs-3'; runState = 'success'; resultMessage = 'Enabled, 1 recovery key'
            lastStateUpdateDateTime = '2026-08-03T10:00:00Z'
            managedDevice = [pscustomobject]@{ id = 'dev-3'; deviceName = 'MacBook-Pro-1'; userPrincipalName = 'c@x.com' }
        }
        [pscustomobject]@{
            id = 'rs-4'; runState = 'scriptError'; resultMessage = $null
            errorCode = 1; errorDescription = 'command not found: fdesetup'
            lastStateUpdateDateTime = '2026-08-03T11:00:00Z'
            managedDevice = [pscustomobject]@{ id = 'dev-4'; deviceName = 'MacBook-Air-9'; userPrincipalName = 'd@x.com' }
        }
    ) }
}

# ---- run states: deviceComplianceScriptDeviceState (its own field names) ----
Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceComplianceScripts/comp-1/deviceRunStates' } -MockWith {
    [pscustomobject]@{ value = @(
        [pscustomobject]@{
            id = 'rs-7'
            detectionState = 'success'
            scriptOutput = '{"TpmVersion":"2.0"}'
            scriptError = $null
            lastStateUpdateDateTime = '2026-08-06T10:00:00Z'
            managedDevice = [pscustomobject]@{ id = 'dev-7'; deviceName = 'LAPTOP-D4'; userPrincipalName = 'g@x.com' }
        }
    ) }
}
'@

}

AfterAll {
    Remove-Module Msec -Force -ErrorAction SilentlyContinue
}

Describe 'Get-MsecIntuneScriptResult' {
    BeforeEach {
        InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } {
            param($Thumb)
            $script:MsecSession = @{
                TenantId = 'tenant'; ClientId = 'client'; KeyVaultName = 'kv-test'
                KeyName = 'msec-app'; ThumbprintBytes = $Thumb; Tokens = @{}
            }
        }
    }

    It 'requires -Source, with no default' {
        # The features overlap in shape but not in meaning; a silent default would make an
        # empty result ambiguous between "no scripts" and "I did not ask".
        (Get-Command Get-MsecIntuneScriptResult).Parameters.Source.Attributes.Mandatory |
            Should -BeTrue
    }

    It 'offers every script source Intune has' {
        # If Microsoft adds a sixth collection this list is where it goes; the five here were
        # taken from Graph's $metadata rather than from the portal's navigation.
        $values = (Get-Command Get-MsecIntuneScriptResult).Parameters.Source.Attributes.ValidValues
        $values | Should -Contain 'Remediation'
        $values | Should -Contain 'PlatformScript'
        $values | Should -Contain 'CustomAttribute'
        $values | Should -Contain 'ComplianceScript'
        $values | Should -Contain 'All'
    }

    Context 'Remediation - deviceHealthScriptDeviceState' {

        It 'keeps both detection outputs' {
            $rows = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                @(Get-MsecIntuneScriptResult -Source Remediation -WarningAction SilentlyContinue)
            }

            @($rows).Count | Should -Be 2

            $fixed = $rows | Where-Object DeviceId -eq 'dev-1'
            $fixed.ScriptName            | Should -Be 'Check-BitLocker'
            $fixed.Source                | Should -Be 'Remediation'
            $fixed.Platform              | Should -Be 'Windows'
            $fixed.DeviceName            | Should -Be 'LAPTOP-A1'
            $fixed.State                 | Should -Be 'success'
            $fixed.RemediationState      | Should -Be 'success'
            # Output is the LATEST thing the detection script said - after the fix.
            $fixed.Output                | Should -Be 'On'
            # ...and both readings survive, so a row where the remediation changed the answer
            # is still legible.
            $fixed.PreRemediationOutput  | Should -Be 'Suspended'
            $fixed.PostRemediationOutput | Should -Be 'On'
            $fixed.PSObject.TypeNames    | Should -Contain 'MsecIntuneScriptResult'
        }

        It 'falls back to the pre-remediation output when no remediation has run' {
            $row = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                @(Get-MsecIntuneScriptResult -Source Remediation -WarningAction SilentlyContinue) |
                    Where-Object DeviceId -eq 'dev-2'
            }

            # Post is $null until a remediation actually runs, so pre IS the current answer.
            $row.Output                | Should -Be 'Off'
            $row.PostRemediationOutput | Should -BeNullOrEmpty
            $row.RemediationState      | Should -Be 'remediationFailed'
            $row.Error                 | Should -Be 'Access denied enabling BitLocker'
        }
    }

    Context 'PlatformScript - both operating systems, one Graph type' {

        It 'reads Windows and macOS platform scripts under one -Source' {
            # One blade in the portal, two collections in Graph. The Platform column tells
            # them apart, which is why they do not need separate -Source values.
            $rows = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                @(Get-MsecIntuneScriptResult -Source PlatformScript)
            }

            @($rows).Count | Should -Be 2

            $win = $rows | Where-Object Platform -eq 'Windows'
            $win.ScriptName | Should -Be 'Set-RegistryTweak'
            $win.Source     | Should -Be 'PlatformScript'
            $win.State      | Should -Be 'success'
            $win.Output     | Should -Be 'Registry value set'

            $mac = $rows | Where-Object Platform -eq 'macOS'
            $mac.ScriptName | Should -Be 'Install-Rosetta'
            $mac.State      | Should -Be 'fail'
            $mac.Error      | Should -Be 'softwareupdate: command failed'
            # No remediation concept here, so those columns stay empty rather than invented.
            $mac.RemediationState      | Should -BeNullOrEmpty
            $mac.PreRemediationOutput  | Should -BeNullOrEmpty
        }
    }

    Context 'CustomAttribute - the attribute value' {

        It 'puts resultMessage in Output' {
            $rows = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                @(Get-MsecIntuneScriptResult -Source CustomAttribute)
            }

            @($rows).Count | Should -Be 2

            $ok = $rows | Where-Object DeviceId -eq 'dev-3'
            $ok.Source   | Should -Be 'CustomAttribute'
            $ok.Platform | Should -Be 'macOS'
            # resultMessage IS the attribute value - the reason the attribute exists.
            $ok.Output   | Should -Be 'Enabled, 1 recovery key'

            $bad = $rows | Where-Object DeviceId -eq 'dev-4'
            $bad.State  | Should -Be 'scriptError'
            $bad.Error  | Should -Be 'command not found: fdesetup'
            $bad.Output | Should -BeNullOrEmpty
        }
    }

    Context 'ComplianceScript - deviceComplianceScriptDeviceState' {

        It 'reads scriptOutput, which is the JSON the compliance rules are evaluated against' {
            # Its own field names: scriptOutput / scriptError / detectionState, none of which
            # the other two shapes use. A custom compliance policy is only as trustworthy as
            # this output.
            $row = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                @(Get-MsecIntuneScriptResult -Source ComplianceScript)
            }

            @($row).Count | Should -Be 1
            $row.ScriptName       | Should -Be 'Check-TpmVersion'
            $row.Source           | Should -Be 'ComplianceScript'
            $row.Platform         | Should -Be 'Windows'
            $row.DeviceName       | Should -Be 'LAPTOP-D4'
            $row.State            | Should -Be 'success'
            $row.Output           | Should -Be '{"TpmVersion":"2.0"}'
            $row.Error            | Should -BeNullOrEmpty
            $row.RemediationState | Should -BeNullOrEmpty
        }
    }

    Context '-Source All' {

        It 'means all five collections, not merely the ones it used to mean' {
            $rows = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                @(Get-MsecIntuneScriptResult -Source All -WarningAction SilentlyContinue)
            }

            # 2 remediation + 2 platform + 2 custom attribute + 1 compliance. 'Never-Ran'
            # contributes none.
            @($rows).Count | Should -Be 7
            @($rows.Source | Sort-Object -Unique) |
                Should -Be @('ComplianceScript', 'CustomAttribute', 'PlatformScript', 'Remediation')
            # One row shape across all of them, which is what makes a single Where-Object work.
            @($rows.Platform | Sort-Object -Unique) | Should -Be @('macOS', 'Windows')
        }
    }

    Context '-Name' {

        It 'reads only the named script, and only that script''s run states' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                # A run-states call for the other script would be wasted work on a tenant
                # with thousands of devices.
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceHealthScripts/rem-2/deviceRunStates' } -MockWith {
                    throw 'the unnamed script must not be read'
                }
                @(Get-MsecIntuneScriptResult -Source Remediation -Name 'Check-BitLocker')
            }

            @($out).Count | Should -Be 2
            ($out.ScriptName | Sort-Object -Unique) | Should -Be 'Check-BitLocker'
        }

        It 'matches case-insensitively, ignores whitespace, and accepts an id' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                [pscustomobject]@{
                    ByName = @(Get-MsecIntuneScriptResult -Source Remediation -Name ' check-bitlocker ')
                    ById   = @(Get-MsecIntuneScriptResult -Source CustomAttribute -Name 'attr-1')
                }
            }
            @($out.ByName).Count | Should -Be 2
            @($out.ById).Count   | Should -Be 2
        }

        It 'throws on an unrecognised name, listing what the tenant has' {
            # A typo returning zero rows would read as "that script has never run".
            $message = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                try { Get-MsecIntuneScriptResult -Source Remediation -Name 'Check-Bitlockr' | Out-Null; '' }
                catch { $_.Exception.Message }
            }

            $message | Should -Match 'Unrecognised script name'
            $message | Should -Match 'Check-Bitlockr'
            $message | Should -Match 'Check-BitLocker'
        }

        It 'throws before emitting anything when one of several names is wrong' {
            # Names are resolved in a first pass precisely so a partial stream is impossible -
            # a truncated result that looks complete is worse than an error.
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                $rows = @()
                try { $rows = @(Get-MsecIntuneScriptResult -Source Remediation -Name 'Check-BitLocker', 'Nope') }
                catch { }
                [pscustomobject]@{ Rows = @($rows) }
            }

            @($out.Rows).Count | Should -Be 0
        }

        It 'accepts a name that matches only one of the five under -Source All' {
            # A Windows remediation name is not expected among the macOS scripts, so a miss is
            # only fatal when nothing matched it anywhere.
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                @(Get-MsecIntuneScriptResult -Source All -Name 'FileVault-Status')
            }

            @($out).Count | Should -Be 2
            ($out.Source | Sort-Object -Unique) | Should -Be 'CustomAttribute'
        }

        It 'warns for a named script with no results, but stays quiet when sweeping' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                $named = Get-MsecIntuneScriptResult -Source Remediation -Name 'Never-Ran' `
                            -WarningVariable wNamed -WarningAction SilentlyContinue
                $swept = Get-MsecIntuneScriptResult -Source Remediation `
                            -WarningVariable wSwept -WarningAction SilentlyContinue
                [pscustomobject]@{
                    NamedRows = @($named); NamedWarnings = @($wNamed)
                    SweptRows = @($swept); SweptWarnings = @($wSwept)
                }
            }

            # Asked for it by name and got nothing - that needs saying.
            @($out.NamedRows).Count         | Should -Be 0
            ($out.NamedWarnings -join "`n") | Should -Match 'nothing has reported yet'
            # Sweeping the tenant, an idle script is ordinary and warning would be noise.
            @($out.SweptRows).Count         | Should -Be 2
            @($out.SweptWarnings).Count     | Should -Be 0
        }
    }

    Context 'degradation' {

        It 'retries without the expand when Graph rejects it, warning once' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))

                # Catch-all first so the specific expand-rejection mock wins.
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'deviceRunStates' } -MockWith {
                    [pscustomobject]@{ value = @(
                        [pscustomobject]@{ id = 'rs-9'; detectionState = 'success'
                                           preRemediationDetectionScriptOutput = 'On' }
                    ) }
                }
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'deviceRunStates\?\$expand=managedDevice' } -MockWith {
                    throw 'Response status code does not indicate success: 400 (Bad Request). Could not parse $expand'
                }

                $rows = Get-MsecIntuneScriptResult -Source Remediation `
                            -WarningVariable w -WarningAction SilentlyContinue
                [pscustomobject]@{ Rows = @($rows); Warnings = @($w) }
            }

            # The script output survives - it is the point of the call.
            @($out.Rows).Count      | Should -BeGreaterThan 0
            $out.Rows[0].Output     | Should -Be 'On'
            # ...and a row is never unattributable: DeviceId falls back to the run-state id.
            $out.Rows[0].DeviceId   | Should -Be 'rs-9'
            $out.Rows[0].DeviceName | Should -BeNullOrEmpty
            # One warning for the run, not one per script - it is an API-version fact.
            @($out.Warnings | Where-Object { $_ -match 'expand' }).Count | Should -Be 1
        }

        It 'rewrites a 403 to name the scripts scope, not the configuration one' {
            # Intune scripts need DeviceManagementScripts.Read.All. Naming the Configuration
            # scope - which the app already holds - sent a real user looking in exactly the
            # wrong place.
            InModuleScope Msec {
                Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                    [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
                }
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceHealthScripts' } -MockWith {
                    throw 'Response status code does not indicate success: 403 (Forbidden).'
                }

                { Get-MsecIntuneScriptResult -Source Remediation } |
                    Should -Throw -ExpectedMessage '*DeviceManagementScripts.Read.All*'
            }
        }
    }
}