Tests/New-MsecApp.Tests.ps1

#Requires -Module Pester
#
# Tests for New-MsecApp. The behaviour that matters is idempotence: it is documented as safe
# to re-run, which means a re-run against an app holding only SOME of the permissions has to
# add the rest - to the app's requiredResourceAccess AND as appRoleAssignments, which are
# what actually grant them.
#
# It also has to SAY so. This step used to report only through Write-Verbose, so a re-run
# that added a dozen permissions printed one line about finding the app and nothing about
# the grants - which is indistinguishable from having done nothing, and was reported as
# exactly that.

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

    # 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.
    $script:MockText = @'
Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'tenant-1' } } }
Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = 'user-token' } }
Mock Get-MsecEnvironment -MockWith {
    [pscustomobject]@{
        EnvironmentName = 'AzureCloud'
        GraphResource = 'https://graph.microsoft.com'
        DefenderResource = 'https://api.securitycenter.microsoft.com'
    }
}

# The certificate already exists and matches, so the run reaches the consent step. Every
# Key Vault cmdlet the function can touch is mocked - Update-AzKeyVaultCertificate runs
# unconditionally, and an unmocked one reaches real Azure from a test run.
Mock Get-AzKeyVaultCertificate -MockWith {
    [pscustomobject]@{ Thumbprint = 'AABB'; Certificate = [pscustomobject]@{ RawData = [byte[]](1..10) } }
}
Mock Update-AzKeyVaultCertificate -MockWith { }
Mock Add-AzKeyVaultCertificate -MockWith { }
Mock New-AzKeyVaultCertificatePolicy -MockWith { [pscustomobject]@{} }
Mock Get-AzKeyVaultCertificateOperation -MockWith { [pscustomobject]@{ Status = 'completed' } }

$script:Calls = [System.Collections.Generic.List[object]]::new()

Mock Invoke-RestMethod -MockWith {
    # The function's Graph helper serialises to JSON before calling Invoke-RestMethod, so
    # $Body here is a STRING. Parsed back so assertions can address its properties -
    # reading .appRoleId off the raw JSON silently yields $null and every assertion passes
    # vacuously.
    $parsed = if ($Body) { $Body | ConvertFrom-Json } else { $null }
    $script:Calls.Add([pscustomobject]@{ Method = [string]$Method; Uri = [string]$Uri; Body = $parsed })
    $u = [string]$Uri

    if ($u -match "servicePrincipals\(appId='00000003-") {
        # Graph exposes every application role msec asks for.
        return [pscustomobject]@{
            id = 'sp-graph'
            appRoles = @($script:GraphRoleValues | ForEach-Object {
                [pscustomobject]@{ value = $_; id = "role-$_"; allowedMemberTypes = @('Application') }
            })
        }
    }
    if ($u -match "servicePrincipals\(appId='fc780465-") {
        return [pscustomobject]@{ id = 'sp-mdatp'; appRoles = @(
            [pscustomobject]@{ value = 'Score.Read.All'; id = 'role-score'; allowedMemberTypes = @('Application') }) }
    }
    if ($u -match '/applications\?\$filter=') {
        return [pscustomobject]@{ value = @([pscustomobject]@{
            id = 'app-obj-1'; appId = 'client-1'; displayName = 'msec'
            requiredResourceAccess = $script:ExistingRRA
            keyCredentials = @()
        }) }
    }
    if ($u -match '/servicePrincipals\?\$filter=appId') {
        return [pscustomobject]@{ value = @([pscustomobject]@{ id = 'sp-app-1' }) }
    }
    if ($u -match '/appRoleAssignments' -and $Method -eq 'GET') {
        return [pscustomobject]@{ value = $script:ExistingGrants }
    }
    [pscustomobject]@{ id = 'generic' }
}
'@

}

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

Describe 'New-MsecApp' {

    Context 're-run against an app missing most permissions' {

        BeforeEach {
            InModuleScope Msec {
                $script:GraphRoleValues = @(
                    'SecurityEvents.Read.All', 'DeviceManagementConfiguration.Read.All',
                    'DeviceManagementManagedDevices.Read.All', 'DeviceManagementScripts.Read.All',
                    'ThreatHunting.Read.All',
                    'SecurityIncident.Read.All', 'Policy.Read.All', 'AuditLog.Read.All',
                    'Organization.Read.All', 'RoleManagement.Read.Directory', 'User.Read.All',
                    'Group.Read.All', 'Application.Read.All',
                    'PrivilegedEligibilitySchedule.Read.AzureADGroup'
                )
                # The app requests, and has consent for, only two of them.
                $script:ExistingRRA = @(
                    [pscustomobject]@{
                        resourceAppId  = '00000003-0000-0000-c000-000000000000'
                        resourceAccess = @(
                            [pscustomobject]@{ id = 'role-SecurityEvents.Read.All'; type = 'Role' }
                            [pscustomobject]@{ id = 'role-Policy.Read.All';         type = 'Role' }
                        )
                    }
                )
                $script:ExistingGrants = @(
                    [pscustomobject]@{ resourceId = 'sp-graph'; appRoleId = 'role-SecurityEvents.Read.All' }
                    [pscustomobject]@{ resourceId = 'sp-graph'; appRoleId = 'role-Policy.Read.All' }
                )
            }
        }

        It 'grants every missing permission and leaves the present ones alone' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                $result = New-MsecApp -KeyVaultName 'kv-test' -InformationAction SilentlyContinue 6>$null
                [pscustomobject]@{ Result = $result; Calls = @($script:Calls) }
            }

            # 14 Graph roles + 1 Defender role = 15 desired, 2 already consented.
            @($out.Result.GrantedNow).Count     | Should -Be 13
            @($out.Result.AlreadyGranted).Count | Should -Be 2

            # One POST per newly granted pair, and none for the two that were already there.
            $posts = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' })
            @($posts).Count | Should -Be 13
            $posts.Body.appRoleId | Should -Not -Contain 'role-SecurityEvents.Read.All'
            $posts.Body.appRoleId | Should -Contain 'role-DeviceManagementManagedDevices.Read.All'
            $posts.Body.appRoleId | Should -Contain 'role-score'

            # Every grant targets the app's own SP as principal, and the resource's SP as
            # resource - transposing those two silently grants nothing useful.
            ($posts.Body.principalId | Sort-Object -Unique) | Should -Be 'sp-app-1'
        }

        It 'merges requiredResourceAccess instead of clobbering it' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                New-MsecApp -KeyVaultName 'kv-test' 6>$null | Out-Null
                [pscustomobject]@{ Calls = @($script:Calls) }
            }

            $patch = @($out.Calls | Where-Object { $_.Method -eq 'PATCH' -and $_.Body.requiredResourceAccess }) |
                        Select-Object -First 1
            $patch | Should -Not -BeNullOrEmpty

            $graphEntry = $patch.Body.requiredResourceAccess |
                Where-Object { $_.resourceAppId -eq '00000003-0000-0000-c000-000000000000' }
            $ids = @($graphEntry.resourceAccess | ForEach-Object { $_.id })

            @($ids).Count | Should -Be 14
            # The two it already had survive...
            $ids | Should -Contain 'role-SecurityEvents.Read.All'
            $ids | Should -Contain 'role-Policy.Read.All'
            # ...and the missing ones are added.
            $ids | Should -Contain 'role-Group.Read.All'
            # No duplicates - re-running must not grow the collection every time.
            @($ids | Sort-Object -Unique).Count | Should -Be 14
        }

        It 'reports the grants on stdout, not only through -Verbose' {
            # THE regression. Silence here was reported as "it does not add the grants".
            $text = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                New-MsecApp -KeyVaultName 'kv-test' 6>&1 | Out-String
            }

            $text | Should -Match '13 granted now'
            $text | Should -Match '2 already present'
            $text | Should -Match 'Group\.Read\.All'
            # And the instruction without which a caller re-runs this, retries, gets the same
            # 403, and concludes the grant failed.
            $text | Should -Match 'Disconnect-Msec'
        }
    }

    Context 'a fully consented app' {

        BeforeEach {
            InModuleScope Msec {
                $script:GraphRoleValues = @('SecurityEvents.Read.All')
                $script:ExistingRRA = @(
                    [pscustomobject]@{
                        resourceAppId  = '00000003-0000-0000-c000-000000000000'
                        resourceAccess = @([pscustomobject]@{ id = 'role-SecurityEvents.Read.All'; type = 'Role' })
                    }
                    [pscustomobject]@{
                        resourceAppId  = 'fc780465-2017-40d4-a0c5-307022471b92'
                        resourceAccess = @([pscustomobject]@{ id = 'role-score'; type = 'Role' })
                    }
                )
                $script:ExistingGrants = @(
                    [pscustomobject]@{ resourceId = 'sp-graph'; appRoleId = 'role-SecurityEvents.Read.All' }
                    [pscustomobject]@{ resourceId = 'sp-mdatp'; appRoleId = 'role-score' }
                )
            }
        }

        It 'grants nothing and does not tell you to reconnect' {
            # Idempotence: a no-op re-run must be visibly a no-op, or the reconnect notice
            # becomes noise that gets ignored on the run where it matters.
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                $text = New-MsecApp -KeyVaultName 'kv-test' 6>&1 | Out-String
                [pscustomobject]@{ Text = $text; Calls = @($script:Calls) }
            }

            @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' }).Count |
                Should -Be 0
            $out.Text | Should -Match '0 granted now'
            $out.Text | Should -Not -Match 'Disconnect-Msec'
        }
    }

    Context 'a cloud that does not offer every role' {

        BeforeEach {
            InModuleScope Msec {
                # Azure China exposes a reduced set of Graph app roles.
                $script:GraphRoleValues = @('SecurityEvents.Read.All', 'Policy.Read.All')
                $script:ExistingRRA = @()
                $script:ExistingGrants = @()
            }
        }

        It 'skips unavailable roles, reports them, and still configures the rest' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                $result = New-MsecApp -KeyVaultName 'kv-test' -WarningVariable w -WarningAction SilentlyContinue 6>$null
                [pscustomobject]@{ Result = $result; Warnings = @($w) }
            }

            # Two Graph roles exist here, plus the Defender one.
            @($out.Result.GrantedNow).Count | Should -Be 3
            # The twelve that do not exist in this cloud are named rather than silently lost.
            @($out.Result.UnavailableRoles).Count | Should -Be 12
            ($out.Warnings -join "`n") | Should -Match 'not available'
            ($out.Warnings -join "`n") | Should -Match 'Group\.Read\.All'
        }
    }
}