tests/Get-MsecIntuneDeviceCompliance.Tests.ps1

BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psd1') -Force

    # Defined here, not in the Describe body: a discovery-time variable is gone by the time an
    # It runs, which reads as the command returning nothing rather than as a test bug.
    $script:Devices = @(
        [pscustomobject]@{ Id = 'd-stale'; DeviceName = 'VIE0001'; Os = 'Windows'
                           UserPrincipalName = 'a@contoso.com'; ComplianceState = 'noncompliant'
                           LastSyncDateTime = (Get-Date).AddDays(-200) }
        [pscustomobject]@{ Id = 'd-secfail'; DeviceName = 'VIE0002'; Os = 'Windows'
                           UserPrincipalName = 'b@contoso.com'; ComplianceState = 'noncompliant'
                           LastSyncDateTime = (Get-Date).AddDays(-1) }
        [pscustomobject]@{ Id = 'd-silent'; DeviceName = 'VIE0003'; Os = 'Windows'
                           UserPrincipalName = 'c@contoso.com'; ComplianceState = 'noncompliant'
                           LastSyncDateTime = (Get-Date).AddDays(-5) }
        [pscustomobject]@{ Id = 'd-ok'; DeviceName = 'VIE0004'; Os = 'Windows'
                           UserPrincipalName = 'd@contoso.com'; ComplianceState = 'compliant'
                           LastSyncDateTime = (Get-Date).AddDays(-1) }
    )
}

Describe 'Get-MsecIntuneDeviceCompliance' {

    BeforeEach {
        InModuleScope msec {
            Mock Assert-MsecSession {}
            Mock Write-Progress {}

            # Assigned into the MODULE's script scope, not closed over. A mock body runs when
            # the mock is invoked, by which time an InModuleScope -Parameters variable has
            # gone out of scope and reads as $null - which looks exactly like the command
            # returning nothing.
            $script:TestDevices = @(
                [pscustomobject]@{ Id = 'd-stale'; DeviceName = 'VIE0001'; Os = 'Windows'
                                   UserPrincipalName = 'a@contoso.com'; ComplianceState = 'noncompliant'
                                   LastSyncDateTime = (Get-Date).AddDays(-200) }
                [pscustomobject]@{ Id = 'd-secfail'; DeviceName = 'VIE0002'; Os = 'Windows'
                                   UserPrincipalName = 'b@contoso.com'; ComplianceState = 'noncompliant'
                                   LastSyncDateTime = (Get-Date).AddDays(-1) }
                [pscustomobject]@{ Id = 'd-silent'; DeviceName = 'VIE0003'; Os = 'Windows'
                                   UserPrincipalName = 'c@contoso.com'; ComplianceState = 'noncompliant'
                                   LastSyncDateTime = (Get-Date).AddDays(-5) }
                [pscustomobject]@{ Id = 'd-ok'; DeviceName = 'VIE0004'; Os = 'Windows'
                                   UserPrincipalName = 'd@contoso.com'; ComplianceState = 'compliant'
                                   LastSyncDateTime = (Get-Date).AddDays(-1) }
            )
            Mock Get-MsecIntuneDevice { $script:TestDevices }

            Mock Invoke-MsecGraphRequest {
                # Policy states.
                if ($Path -match '/managedDevices/d-stale/deviceCompliancePolicyStates$') {
                    return @(@{ id = 'p-default'; displayName = 'Default Device Compliance Policy'; state = 'nonCompliant' })
                }
                if ($Path -match '/managedDevices/d-secfail/deviceCompliancePolicyStates$') {
                    return @(@{ id = 'p-base'; displayName = 'Baseline Windows'; state = 'nonCompliant' })
                }
                # Flagged non-compliant, but no failing policy comes back at all.
                if ($Path -match '/managedDevices/d-silent/deviceCompliancePolicyStates$') { return @() }
                if ($Path -match '/managedDevices/d-ok/deviceCompliancePolicyStates$') { return @() }

                # Setting states.
                if ($Path -match 'p-default/settingStates') {
                    return @(
                        @{ settingName = 'DefaultDeviceCompliancePolicy.RequireRemainContact'; state = 'nonCompliant'; setting = '' }
                        @{ settingName = 'DefaultDeviceCompliancePolicy.RequireUserExistence'; state = 'compliant'; setting = '' }
                    )
                }
                if ($Path -match 'p-base/settingStates') {
                    return @(@{ settingName = 'BitLockerEnabled'; state = 'nonCompliant'; setting = '' })
                }
                return @()
            }
        }
    }

    It 'separates a housekeeping failure from a real security control' {
        # THE POINT OF THE COMMAND. The built-in default policy checks whether the device has
        # checked in and whether its owner still exists - a device failing those is stale, not
        # insecure, and reporting both as 'non-compliant' is how a real failure gets buried.
        InModuleScope msec {
            $r = Get-MsecIntuneDeviceCompliance
            ($r | Where-Object DeviceName -eq 'VIE0001').Category | Should -Be 'Housekeeping'
            ($r | Where-Object DeviceName -eq 'VIE0002').Category | Should -Be 'SecurityControl'
        }
    }

    It '-SecurityOnly drops housekeeping but KEEPS rows it could not attribute' {
        # Unattributed survives the filter deliberately: an unreadable reason might be a
        # security failure, and the view someone uses to hunt for those is the worst possible
        # place for it to disappear from. Confirmed-only is Where-Object Category.
        InModuleScope msec {
            $r = Get-MsecIntuneDeviceCompliance -SecurityOnly
            $r.DeviceName | Should -Be @('VIE0002', 'VIE0003')
            ($r | Where-Object Category -eq 'SecurityControl').Setting | Should -Be @('BitLockerEnabled')
            ($r | Where-Object Category -eq 'Housekeeping')            | Should -BeNullOrEmpty
        }
    }

    It 'reports a non-compliant device whose reason cannot be read, rather than dropping it' {
        # Measured on a live tenant: 4 of 33 devices returned no failing policy at all.
        # Omitting them shrinks the apparent problem, and 'we could not tell why' must never
        # look like 'there is nothing wrong'.
        InModuleScope msec {
            $row = Get-MsecIntuneDeviceCompliance | Where-Object DeviceName -eq 'VIE0003'
            $row          | Should -Not -BeNullOrEmpty
            $row.Category | Should -Be 'Unattributed'
            $row.Setting  | Should -BeNullOrEmpty
        }
    }

    It 'reads settingName rather than the always-empty setting property' {
        # v1.0 returns 'setting' present and blank on every row; the readable rule is in
        # 'settingName'. Reading the obvious one yields a column of blanks.
        InModuleScope msec {
            (Get-MsecIntuneDeviceCompliance | Where-Object DeviceName -eq 'VIE0002').Setting |
                Should -Be 'BitLockerEnabled'
        }
    }

    It 'skips compliant devices by default, because each one costs two Graph calls' {
        InModuleScope msec {
            (Get-MsecIntuneDeviceCompliance).DeviceName | Should -Not -Contain 'VIE0004'
            Should -Invoke Invoke-MsecGraphRequest -Times 0 -Exactly -ParameterFilter {
                $Path -match 'd-ok'
            }
        }
    }

    It '-IncludeCompliant evaluates them too' {
        InModuleScope msec {
            Get-MsecIntuneDeviceCompliance -IncludeCompliant | Out-Null
            Should -Invoke Invoke-MsecGraphRequest -Times 1 -Exactly -ParameterFilter {
                $Path -match 'd-ok/deviceCompliancePolicyStates$'
            }
        }
    }

    It 'only emits rows for settings that actually failed' {
        # RequireUserExistence is compliant on the stale device and must not appear as a
        # reason it is non-compliant.
        InModuleScope msec {
            $r = Get-MsecIntuneDeviceCompliance | Where-Object DeviceName -eq 'VIE0001'
            @($r).Count | Should -Be 1
            $r.Setting  | Should -Be 'DefaultDeviceCompliancePolicy.RequireRemainContact'
        }
    }

    It 'carries DaysQuiet, because an unreachable device cannot be remediated at all' {
        InModuleScope msec {
            $r = Get-MsecIntuneDeviceCompliance | Where-Object DeviceName -eq 'VIE0001'
            $r.DaysQuiet | Should -BeGreaterThan 190
        }
    }

    It '-DeviceName filters before doing any work' {
        InModuleScope msec {
            (Get-MsecIntuneDeviceCompliance -DeviceName 'VIE0002').DeviceName | Should -Be @('VIE0002')
            Should -Invoke Invoke-MsecGraphRequest -Times 0 -Exactly -ParameterFilter {
                $Path -match 'd-stale'
            }
        }
    }

    It 'names the missing permission when Graph refuses' {
        InModuleScope msec {
            Mock Invoke-MsecGraphRequest { throw 'Response status code does not indicate success: 403 (Forbidden).' }
            { Get-MsecIntuneDeviceCompliance } | Should -Throw '*DeviceManagementManagedDevices.Read.All*'
        }
    }


    It 'reports a THROTTLED read as Unreadable, not as a clean or unattributed device' {
        # The defect this distinction fixes. Intune throttles two-calls-per-device hard, the
        # Graph helper gives up after 5 retries, and the original code caught that and
        # emitted the device as though nothing could be found wrong with it. Measured: the
        # same tenant returned 4 unattributed devices on one run and 9 on the next, purely
        # because the 429s landed on different devices - and one of the 'unattributed' ones
        # had three perfectly readable settings when queried on its own.
        InModuleScope msec {
            Mock Invoke-MsecGraphRequest {
                if ($Path -match 'd-stale') { throw 'Response status code does not indicate success: 429 (TooManyRequests).' }
                if ($Path -match '/managedDevices/d-secfail/deviceCompliancePolicyStates$') {
                    return @(@{ id = 'p-base'; displayName = 'Baseline Windows'; state = 'nonCompliant' })
                }
                if ($Path -match 'p-base/settingStates') {
                    return @(@{ settingName = 'BitLockerEnabled'; state = 'nonCompliant'; setting = '' })
                }
                return @()
            }
            $warnings = @()
            $r = Get-MsecIntuneDeviceCompliance -WarningVariable warnings -WarningAction SilentlyContinue
            ($r | Where-Object DeviceName -eq 'VIE0001').Category | Should -Be 'Unreadable'
            # VIE0003 genuinely returns nothing - that stays Unattributed, so the two
            # remain distinguishable.
            ($r | Where-Object DeviceName -eq 'VIE0003').Category | Should -Be 'Unattributed'
            ($warnings -join ' ') | Should -Match 'throttl'
        }
    }
    It 'emits the documented type so the table view applies' {
        InModuleScope msec {
            (Get-MsecIntuneDeviceCompliance)[0].PSObject.TypeNames |
                Should -Contain 'MsecIntuneDeviceCompliance'
        }
    }
}