tests/Get-MsecEntraConditionalAccessChange.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecEntraConditionalAccessChange. Entra records a CA change as one property
# whose old and new values are the WHOLE policy as JSON; the command's job is to diff them.
#
# Covered, each from the real event shape:
# - the diff reports only fields that moved, out of a large object
# - modifiedDateTime is EXCLUDED: it changes on every edit, so leaving it in puts a
# meaningless entry on every single row and buries the real one
# - state is lifted into its own column, because enabled -> disabled is the highest-signal
# CA change and is one field inside a big blob
# - an APP actor is named: measured on a live tenant, 11 of 15 changes came from a
# Microsoft365DSC service principal, so reading only initiatedBy.user loses the majority
# - an empty result WARNS, because 30-day audit retention means silence is not stability

BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop
}
AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue }

Describe 'Get-MsecEntraConditionalAccessChange' {
    BeforeEach {
        InModuleScope Msec { Mock Assert-MsecSession -MockWith { } }
    }

    It 'diffs the policy JSON and reports only what moved, excluding modifiedDateTime' {
        $row = InModuleScope Msec {
            $old = @{ id='p1'; displayName='Require MFA'; state='enabled'
                      createdDateTime='2026-01-01T00:00:00Z'; modifiedDateTime='2026-10-08T07:00:00Z'
                      grantControls=@{ builtInControls=@('mfa') }
                      conditions=@{ clientAppTypes=@('all') } } | ConvertTo-Json -Depth 8
            $new = @{ id='p1'; displayName='Require MFA'; state='enabled'
                      createdDateTime='2026-01-01T00:00:00Z'; modifiedDateTime='2026-10-08T12:00:00Z'
                      grantControls=@{ builtInControls=@() }
                      conditions=@{ clientAppTypes=@('all') } } | ConvertTo-Json -Depth 8
            Mock Invoke-MsecGraphRequest -MockWith {
                @([pscustomobject]@{
                    activityDisplayName = 'Update conditional access policy'
                    activityDateTime = '2026-10-08T12:00:00Z'; result = 'success'; correlationId = 'c1'
                    initiatedBy = [pscustomobject]@{ user = [pscustomobject]@{ userPrincipalName = 'me@contoso.com' } }
                    targetResources = @([pscustomobject]@{
                        displayName = 'Require MFA'; id = 'p1'
                        modifiedProperties = @([pscustomobject]@{
                            displayName = 'ConditionalAccessPolicy'; oldValue = $old; newValue = $new })
                    })
                })
            }
            Get-MsecEntraConditionalAccessChange
        }

        $row.PolicyName | Should -Be 'Require MFA'
        $row.Actor | Should -Be 'me@contoso.com'
        $row.ActorType | Should -Be 'User'
        # The real change: MFA removed from the grant controls.
        ($row.ChangedProperties -join ' ') | Should -Match 'grantControls'
        # Noise that moves on every single edit must not be reported as a change.
        ($row.ChangedProperties -join ' ') | Should -Not -Match 'modifiedDateTime'
        ($row.ChangedProperties -join ' ') | Should -Not -Match 'createdDateTime'
        # Unchanged fields stay out.
        ($row.ChangedProperties -join ' ') | Should -Not -Match 'clientAppTypes'
    }

    It 'lifts a state change into its own column' {
        $row = InModuleScope Msec {
            $old = @{ id='p2'; displayName='X'; state='enabledForReportingButNotEnforced' } | ConvertTo-Json
            $new = @{ id='p2'; displayName='X'; state='enabled' } | ConvertTo-Json
            Mock Invoke-MsecGraphRequest -MockWith {
                @([pscustomobject]@{
                    activityDisplayName='Update conditional access policy'
                    activityDateTime='2026-10-08T12:12:16Z'; result='success'
                    initiatedBy=[pscustomobject]@{ app=[pscustomobject]@{ displayName='sc-m365dsc-ca-orchestrator' } }
                    targetResources=@([pscustomobject]@{ displayName='X'; id='p2'
                        modifiedProperties=@([pscustomobject]@{ displayName='ConditionalAccessPolicy'; oldValue=$old; newValue=$new }) })
                })
            }
            Get-MsecEntraConditionalAccessChange
        }

        $row.StateBefore | Should -Be 'enabledForReportingButNotEnforced'
        $row.StateAfter | Should -Be 'enabled'
        $row.StateChanged | Should -BeTrue
        # Filterable without parsing diffs - the point of the column.
    }

    It 'names an application actor, since most CA changes on an automated tenant have no user' {
        $row = InModuleScope Msec {
            $new = @{ id='p3'; displayName='New policy'; state='enabled' } | ConvertTo-Json
            Mock Invoke-MsecGraphRequest -MockWith {
                @([pscustomobject]@{
                    activityDisplayName='Add conditional access policy'
                    activityDateTime='2026-10-08T13:18:04Z'; result='success'
                    initiatedBy=[pscustomobject]@{
                        user=[pscustomobject]@{ userPrincipalName=$null }
                        app=[pscustomobject]@{ displayName='sc-m365dsc-ca-orchestrator' } }
                    targetResources=@([pscustomobject]@{ displayName='New policy'; id='p3'
                        modifiedProperties=@([pscustomobject]@{ displayName='ConditionalAccessPolicy'; oldValue=''; newValue=$new }) })
                })
            }
            Get-MsecEntraConditionalAccessChange
        }

        $row.Actor | Should -Be 'sc-m365dsc-ca-orchestrator'
        $row.ActorType | Should -Be 'Application'
        $row.Activity | Should -Match 'Add'
        # A create has no old value; every field reads as new rather than the row being empty.
        $row.ChangedCount | Should -BeGreaterThan 0
    }

    It 'filters by policy name and by actor' {
        $out = InModuleScope Msec {
            function New-Evt($pol, $who) {
                [pscustomobject]@{
                    activityDisplayName='Update conditional access policy'
                    activityDateTime='2026-10-08T12:00:00Z'; result='success'
                    initiatedBy=[pscustomobject]@{ user=[pscustomobject]@{ userPrincipalName=$who } }
                    targetResources=@([pscustomobject]@{ displayName=$pol; id=$pol
                        modifiedProperties=@([pscustomobject]@{ displayName='ConditionalAccessPolicy'
                            oldValue=(@{ id=$pol; state='enabled' } | ConvertTo-Json)
                            newValue=(@{ id=$pol; state='disabled' } | ConvertTo-Json) }) })
                }
            }
            Mock Invoke-MsecGraphRequest -MockWith { @((New-Evt 'CA901-Emergency' 'a@x.com'), (New-Evt 'CA500-Guests' 'b@x.com')) }
            [pscustomobject]@{
                ByName  = @(Get-MsecEntraConditionalAccessChange -PolicyName 'Emergency').PolicyName
                ByActor = @(Get-MsecEntraConditionalAccessChange -Actor 'b@x.com').PolicyName
            }
        }

        $out.ByName  | Should -Be 'CA901-Emergency'
        $out.ByActor | Should -Be 'CA500-Guests'
    }

    It 'warns on an empty result, because 30-day retention makes silence ambiguous' {
        $warning = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -MockWith { @() }
            $w = @()
            Get-MsecEntraConditionalAccessChange -WarningVariable w -WarningAction SilentlyContinue | Out-Null
            "$($w -join ' ')"
        }

        $warning | Should -Match 'NOT evidence that the policies are unchanged'
        # Points at the property that outlives the audit log.
        $warning | Should -Match 'ModifiedDateTime'
    }

    It 'rewrites a 403 to mention both the permission and the licence' {
        $err = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' }
            try { Get-MsecEntraConditionalAccessChange; $null } catch { "$($_.Exception.Message)" }
        }

        $err | Should -Match 'AuditLog\.Read\.All'
        # A free tenant has no directory audit log at all, which a permission error does not convey.
        $err | Should -Match 'Entra ID P1'
    }
}