tests/Get-MsecIntuneAuditEvent.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecIntuneAuditEvent. The function GETs /deviceManagement/auditEvents with an
# activityDateTime filter and projects each change to a flat PSCustomObject.
#
# The tests target the things that are easy to get wrong and silent when they are:
# - ChangedProperties carries 'Setting: old -> new' per modified property, which is the
# whole reason the command exists.
# - An actor with no userPrincipalName (a change made by an app) is still named, rather
# than reported as having no author.
# - An EMPTY result warns, because "nothing changed" and "out of retention" are the same
# empty array and only one of them is an answer.
# - Result is tested for SUCCESS, so an unfamiliar activityResult counts as a failure and
# survives -FailedOnly rather than being silently filtered away.
# - 403 is rewritten to name DeviceManagementApps.Read.All specifically - the Intune APPS
# scope, which is not implied by any of the other Intune permissions the module holds.

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

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

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

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

            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 }
            }
        }
    }

    It 'projects a change to the flat shape and renders each modified property as old -> new' {
        $row = InModuleScope Msec {
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id                    = 'evt-1'
                        displayName           = 'Patch DeviceConfiguration'
                        componentName         = 'DeviceConfiguration'
                        activity              = 'Patch DeviceConfiguration'
                        activityDateTime      = '2026-08-14T09:30:00Z'
                        activityType          = 'Patch'
                        activityOperationType = 'Patch'
                        activityResult        = 'Success'
                        correlationId         = 'corr-1'
                        category              = 'DeviceConfiguration'
                        actor                 = [pscustomobject]@{
                            userPrincipalName      = 'someone@contoso.com'
                            servicePrincipalName   = $null
                            applicationDisplayName = 'Microsoft Intune portal'
                            auditActorType         = 'ItPro'
                            ipAddress              = '203.0.113.9'
                            applicationId          = 'app-1'
                        }
                        resources             = @(
                            [pscustomobject]@{
                                displayName       = 'ASR baseline'
                                type              = 'DeviceConfiguration'
                                auditResourceType = 'DeviceConfiguration'
                                resourceId        = 'res-1'
                                modifiedProperties = @(
                                    [pscustomobject]@{ displayName = 'BlockSafeModeReboot'; oldValue = 'audit'; newValue = 'block' }
                                    [pscustomobject]@{ displayName = 'DisplayName';         oldValue = $null;   newValue = 'ASR baseline' }
                                )
                            }
                        )
                    }
                ) }
            }
            Get-MsecIntuneAuditEvent -Days 365
        }

        $row.Id | Should -Be 'evt-1'
        $row.Activity | Should -Be 'Patch DeviceConfiguration'
        $row.Category | Should -Be 'DeviceConfiguration'
        $row.Actor | Should -Be 'someone@contoso.com'
        $row.ActorIpAddress | Should -Be '203.0.113.9'
        $row.Succeeded | Should -BeTrue
        $row.ActivityDateTime | Should -BeOfType [datetime]
        $row.Resource | Should -Contain 'ASR baseline'

        # The point of the command: the setting that moved, with both values.
        $row.ChangedCount | Should -Be 2
        $row.ChangedProperties | Should -Contain 'BlockSafeModeReboot: audit -> block'
        # A null old value renders as (none), not as an empty gap that reads like a missing field.
        $row.ChangedProperties | Should -Contain 'DisplayName: (none) -> ASR baseline'
    }

    It 'names an actor that has no userPrincipalName, so app-made changes are not reported as authorless' {
        $row = InModuleScope Msec {
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'evt-2'; activity = 'Delete'; activityDateTime = '2026-08-01T00:00:00Z'
                        activityResult = 'Success'; category = 'Application'
                        actor = [pscustomobject]@{
                            userPrincipalName      = $null
                            servicePrincipalName   = 'automation-sp'
                            applicationDisplayName = 'Terraform'
                            auditActorType         = 'Application'
                        }
                        resources = @()
                    }
                ) }
            }
            Get-MsecIntuneAuditEvent
        }

        $row.Actor | Should -Be 'automation-sp'
        $row.ActorType | Should -Be 'Application'
        # No resources means no properties moved - zero, not null, and not an error.
        $row.ChangedCount | Should -Be 0
    }

    It 'warns on an empty result instead of returning silence, because out-of-retention looks identical' {
        $warnings = InModuleScope Msec {
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            $w = @()
            Get-MsecIntuneAuditEvent -Days 365 -WarningVariable w -WarningAction SilentlyContinue | Out-Null
            , $w
        }

        $warnings.Count | Should -BeGreaterThan 0
        "$($warnings[0])" | Should -Match 'NOT the same as'
    }

    It 'treats an unrecognised activityResult as a failure, so -FailedOnly cannot hide one' {
        $rows = InModuleScope Msec {
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id='ok';   activity='A'; activityDateTime='2026-08-01T00:00:00Z'; activityResult='Success';            actor=[pscustomobject]@{}; resources=@() }
                    [pscustomobject]@{ id='bad';  activity='B'; activityDateTime='2026-08-01T00:00:00Z'; activityResult='Fail';               actor=[pscustomobject]@{}; resources=@() }
                    [pscustomobject]@{ id='odd';  activity='C'; activityDateTime='2026-08-01T00:00:00Z'; activityResult='PartiallyCompleted'; actor=[pscustomobject]@{}; resources=@() }
                ) }
            }
            , @(Get-MsecIntuneAuditEvent -FailedOnly)
        }

        @($rows | ForEach-Object Id) | Should -Be @('bad', 'odd')
    }

    It 'filters by resource name, activity and actor without matching regex metacharacters literally' {
        $rows = InModuleScope Msec {
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id='a'; activity='Patch DeviceConfiguration'; activityDateTime='2026-08-01T00:00:00Z'
                        activityResult='Success'; category='DeviceConfiguration'
                        actor=[pscustomobject]@{ userPrincipalName='alex.diaz@contoso.com' }
                        resources=@([pscustomobject]@{ displayName='Attack Surface Reduction (ASR)'; type='DeviceConfiguration' })
                    }
                    [pscustomobject]@{
                        id='b'; activity='Create Application'; activityDateTime='2026-08-01T00:00:00Z'
                        activityResult='Success'; category='Application'
                        actor=[pscustomobject]@{ userPrincipalName='someone.else@contoso.com' }
                        resources=@([pscustomobject]@{ displayName='Company Portal'; type='Application' })
                    }
                ) }
            }
            [pscustomobject]@{
                ByResource = @(Get-MsecIntuneAuditEvent -Resource 'Attack Surface').Id
                ByActivity = @(Get-MsecIntuneAuditEvent -Activity 'Patch').Id
                ByActor    = @(Get-MsecIntuneAuditEvent -Actor 'alex').Id
                ByCategory = @(Get-MsecIntuneAuditEvent -Category 'Application').Id
                # '(ASR)' contains regex metacharacters. Unescaped, this would match nothing
                # or throw; the function escapes the needle before matching.
                ByParens   = @(Get-MsecIntuneAuditEvent -Resource 'Reduction (ASR)').Id
            }
        }

        $rows.ByResource | Should -Be 'a'
        $rows.ByActivity | Should -Be 'a'
        $rows.ByActor    | Should -Be 'a'
        $rows.ByCategory | Should -Be 'b'
        $rows.ByParens   | Should -Be 'a'
    }

    It 'asks Graph for the requested window with an unquoted ISO 8601 literal' {
        $uri = InModuleScope Msec {
            $script:captured = $null
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                $script:captured = $Uri
                [pscustomobject]@{ value = @() }
            }
            Get-MsecIntuneAuditEvent -Days 365 -WarningAction SilentlyContinue | Out-Null
            $script:captured
        }

        $decoded = [uri]::UnescapeDataString($uri)
        $decoded | Should -Match 'activityDateTime ge \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z'
        # Quoting the datetime is a filter-parse 400 that does not name the date.
        $decoded | Should -Not -Match "activityDateTime ge '"
    }

    It 'rewrites a 403 to name DeviceManagementApps.Read.All, not the other Intune scopes' {
        $err = InModuleScope Msec {
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }
            try { Get-MsecIntuneAuditEvent; $null }
            catch { "$($_.Exception.Message)" }
        }

        $err | Should -Match 'DeviceManagementApps\.Read\.All'
        $err | Should -Match 'New-MsecApp'
        # The whole point of the message is that the obvious-looking scopes do NOT cover this.
        $err | Should -Match 'AuditLog\.Read\.All'
    }

    It 'filters by ResourceType, and falls back to displayName when Graph returns a blank activity' {
        $out = InModuleScope Msec {
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id='p'; activity=''; displayName='Create device configuration 2.0 (beta)'
                        activityDateTime='2026-02-27T09:15:20Z'; activityResult='Success'; category='DeviceConfiguration'
                        actor=[pscustomobject]@{ userPrincipalName='admin.account@contoso.com' }
                        resources=@([pscustomobject]@{ displayName='Block use of copied or impersonated system tools'
                                                       auditResourceType='DeviceManagementConfigurationPolicy' })
                    }
                    [pscustomobject]@{
                        id='d'; activity=''; displayName='Sync device'
                        activityDateTime='2026-02-27T09:15:20Z'; activityResult='Success'; category='Device'
                        actor=[pscustomobject]@{ userPrincipalName='someone@contoso.com' }
                        resources=@([pscustomobject]@{ displayName='LAPTOP-1'; auditResourceType='ManagedDevice' })
                    }
                ) }
            }
            [pscustomobject]@{
                Policies = @(Get-MsecIntuneAuditEvent -Days 365 -ResourceType 'DeviceManagementConfigurationPolicy')
                All      = @(Get-MsecIntuneAuditEvent -Days 365)
            }
        }

        @($out.Policies).Count | Should -Be 1
        $out.Policies[0].Id | Should -Be 'p'
        # Graph returned activity as an empty string on both rows; the column must still say
        # what happened rather than rendering blank.
        $out.Policies[0].Activity | Should -Be 'Create device configuration 2.0 (beta)'
        @($out.All | ForEach-Object Activity) | Should -Not -Contain ''
    }

    It 'warns with the resource names actually present when a filter matches nothing, ranked by frequency' {
        $warning = InModuleScope Msec {
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                [pscustomobject]@{ value = @(
                    # One rarely-touched policy and one touched three times. Busiest must lead:
                    # sorted by name, 'Aaa rare policy' would come first and bury the answer.
                    [pscustomobject]@{ id='1'; displayName='x'; activityDateTime='2026-02-27T09:00:00Z'; activityResult='Success'; category='DeviceConfiguration'
                                       actor=[pscustomobject]@{}; resources=@([pscustomobject]@{ displayName='Aaa rare policy'; auditResourceType='DeviceManagementConfigurationPolicy' }) }
                    [pscustomobject]@{ id='2'; displayName='x'; activityDateTime='2026-02-27T09:00:00Z'; activityResult='Success'; category='DeviceConfiguration'
                                       actor=[pscustomobject]@{}; resources=@([pscustomobject]@{ displayName='Block use of copied or impersonated system tools'; auditResourceType='DeviceManagementConfigurationPolicy' }) }
                    [pscustomobject]@{ id='3'; displayName='x'; activityDateTime='2026-02-27T09:00:00Z'; activityResult='Success'; category='DeviceConfiguration'
                                       actor=[pscustomobject]@{}; resources=@([pscustomobject]@{ displayName='Block use of copied or impersonated system tools'; auditResourceType='DeviceManagementConfigurationPolicy' }) }
                    [pscustomobject]@{ id='4'; displayName='x'; activityDateTime='2026-02-27T09:00:00Z'; activityResult='Success'; category='DeviceConfiguration'
                                       actor=[pscustomobject]@{}; resources=@([pscustomobject]@{ displayName='Block use of copied or impersonated system tools'; auditResourceType='DeviceManagementConfigurationPolicy' }) }
                ) }
            }
            $w = @()
            Get-MsecIntuneAuditEvent -Days 365 -Resource 'Attack Surface' -WarningVariable w -WarningAction SilentlyContinue | Out-Null
            "$($w -join ' ')"
        }

        # The distinction that cost an afternoon: events WERE returned, the filter missed.
        $warning | Should -Match 'FILTER miss and NOT an empty audit log'
        $warning | Should -Match '4 audit event'
        $warning | Should -Match 'Block use of copied or impersonated system tools'
        # Busiest first, so the three-hit policy precedes the one-hit one.
        $warning.IndexOf('Block use of copied') | Should -BeLessThan $warning.IndexOf('Aaa rare policy')
    }

    It 'does not warn about filters when the audit log itself is empty - that is a different message' {
        $warning = InModuleScope Msec {
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceManagement/auditEvents' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            $w = @()
            Get-MsecIntuneAuditEvent -Days 365 -Resource 'anything' -WarningVariable w -WarningAction SilentlyContinue | Out-Null
            "$($w -join ' ')"
        }

        $warning | Should -Match 'NOT the same as'
        $warning | Should -Not -Match 'FILTER miss'
    }
}