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-0000-0000-c000-") {
        # 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') }
            [pscustomobject]@{ value = 'Machine.Read.All'; id = 'role-machine'; allowedMemberTypes = @('Application') }
            [pscustomobject]@{ value = 'Vulnerability.Read.All'; id = 'role-vuln'; allowedMemberTypes = @('Application') }) }
    }
    # Office 365 Exchange Online and SharePoint - only reached with -Workload.
    if ($u -match "servicePrincipals\(appId='00000002-0000-0ff1-ce00-") {
        if ($script:ExchangeSpMissing) { throw 'Request_ResourceNotFound' }
        return [pscustomobject]@{ id = 'sp-exo'; appRoles = @(
            [pscustomobject]@{ value = 'Exchange.ManageAsApp'; id = 'role-exo'; allowedMemberTypes = @('Application') }) }
    }
    if ($u -match "servicePrincipals\(appId='48ac35b8-9aa8-4d74-927d-1f4a14a0b239") {
        return [pscustomobject]@{ id = 'sp-teams'; appRoles = @(
            [pscustomobject]@{ value = 'application_access'; id = 'role-teams'; allowedMemberTypes = @('Application') }) }
    }
    if ($u -match "servicePrincipals\(appId='00000003-0000-0ff1-ce00-") {
        return [pscustomobject]@{ id = 'sp-spo'; appRoles = @(
            [pscustomobject]@{ value = 'Sites.Read.All'; id = 'role-spo-sites'; allowedMemberTypes = @('Application') }) }
    }
    if ($u -match '/roleManagement/directory/roleDefinitions') {
        if ($script:RoleDefinitionMissing) { return [pscustomobject]@{ value = @() } }
        return [pscustomobject]@{ value = @([pscustomobject]@{ id = 'roledef-globalreader'; displayName = 'Global Reader' }) }
    }
    if ($u -match '/roleManagement/directory/roleAssignments') {
        if ($Method -eq 'POST') {
            if ($script:RoleAssignDenied) { throw 'Authorization_RequestDenied' }
            return [pscustomobject]@{ id = 'assignment-1' }
        }
        return [pscustomobject]@{ value = @($script:ExistingRoleAssignments) }
    }
    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 + 3 Defender roles = 17 desired, 2 already consented.
            @($out.Result.GrantedNow).Count     | Should -Be 15
            @($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 15
            $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'
            $posts.Body.appRoleId | Should -Contain 'role-machine'
            $posts.Body.appRoleId | Should -Contain 'role-vuln'

            # 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 '15 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' }
                            [pscustomobject]@{ id = 'role-machine'; type = 'Role' }
                            [pscustomobject]@{ id = 'role-vuln';    type = 'Role' }
                        )
                    }
                )
                $script:ExistingGrants = @(
                    [pscustomobject]@{ resourceId = 'sp-graph'; appRoleId = 'role-SecurityEvents.Read.All' }
                    [pscustomobject]@{ resourceId = 'sp-mdatp'; appRoleId = 'role-score' }
                    [pscustomobject]@{ resourceId = 'sp-mdatp'; appRoleId = 'role-machine' }
                    [pscustomobject]@{ resourceId = 'sp-mdatp'; appRoleId = 'role-vuln' }
                )
            }
        }

        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 three Defender ones - the reduced set this
            # context stands for is a GRAPH reduction; a cloud with no Defender at all skips
            # that resource entirely rather than finding its roles missing.
            @($out.Result.GrantedNow).Count | Should -Be 5
            # The twelve Graph roles that do not exist in this cloud are named rather than
            # silently lost.
            # This count is deliberately literal: it has to be updated by hand whenever a
            # permission is added to $resources, which is the point - a role that vanishes
            # from the list should fail a test rather than quietly stop being requested.
            @($out.Result.UnavailableRoles).Count | Should -Be 12
            ($out.Warnings -join "`n") | Should -Match 'not available'
            ($out.Warnings -join "`n") | Should -Match 'Group\.Read\.All'
        }
    }

    Context 'workloads beyond Graph and Defender' {

        BeforeEach {
            InModuleScope Msec {
                $script:GraphRoleValues = @('SecurityEvents.Read.All', 'Policy.Read.All',
                                            'Sites.Read.All', 'SharePointTenantSettings.Read.All')
                $script:ExistingRRA = @()
                $script:ExistingGrants = @()
                $script:ExistingRoleAssignments = @()
                $script:RoleAssignDenied = $false
                $script:RoleDefinitionMissing = $false
                $script:ExchangeSpMissing = $false
                # Shared across the whole file and never cleared otherwise, so "no calls of
                # this kind were made" would be answered by an earlier test's calls.
                $script:Calls = [System.Collections.Generic.List[object]]::new()
            }
        }

        It 'grants nothing extra unless a workload is asked for' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                New-MsecApp -KeyVaultName 'kv-test' -WarningAction SilentlyContinue 6>$null
                [pscustomobject]@{ Calls = @($script:Calls) }
            }

            # Exchange needs a directory role and SharePoint needs fresh consent, so neither is
            # a side effect of an ordinary bootstrap.
            @($out.Calls | Where-Object Uri -match '0ff1-ce00').Count | Should -Be 0
            # The DIRECTORY role path specifically. Matching bare 'roleAssignments' also
            # catches '/appRoleAssignments', which every ordinary grant uses - so the loose
            # pattern counts six routine calls and reads as a failure.
            @($out.Calls | Where-Object Uri -match '/roleManagement/directory/roleAssignments').Count | Should -Be 0
        }

        It 'grants SharePoint on the SharePoint service principal, not on Graph' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                $result = New-MsecApp -KeyVaultName 'kv-test' -Workload SharePoint -WarningAction SilentlyContinue 6>$null
                [pscustomobject]@{ Result = $result; Calls = @($script:Calls) }
            }

            # Sites.Read.All exists on BOTH Microsoft Graph and SharePoint with the same name.
            # PnP presents a SharePoint-audience token, so the grant must target sp-spo -
            # granting the Graph one looks right in the portal and still fails.
            $grant = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' -and $_.Body.appRoleId -eq 'role-spo-sites' })
            @($grant).Count | Should -Be 1
            $grant[0].Body.resourceId | Should -Be 'sp-spo'

            $out.Result.GrantedNow | Should -Contain 'Office 365 SharePoint Online: Sites.Read.All'

            # The tenant-wide sharing posture - SharingCapability, the domain allow-list, the
            # restriction mode - lives at /admin/sharepoint/settings and needs a permission of
            # its own. Sites.Read.All reads SITES, not tenant settings, and the call 403s with
            # 'Caller does not have required permissions for this API', naming nothing.
            $tenantGrant = @($out.Calls | Where-Object {
                $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' -and
                $_.Body.appRoleId -eq 'role-SharePointTenantSettings.Read.All'
            })
            @($tenantGrant).Count | Should -Be 1
            $tenantGrant[0].Body.resourceId | Should -Be 'sp-graph'

            # SharePoint needs no directory role - that is Exchange's problem alone.
            $out.Result.DirectoryRole | Should -BeNullOrEmpty
        }

        It 'assigns the directory role for Exchange, because the app role alone is not enough' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                $result = New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange -WarningAction SilentlyContinue 6>$null
                [pscustomobject]@{ Result = $result; Calls = @($script:Calls) }
            }

            # The app role...
            @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Body.appRoleId -eq 'role-exo' }).Count | Should -Be 1

            # ...AND the directory role. Without the second, every Get-EXO* fails with an
            # authorisation error that names no missing permission.
            $assign = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/roleManagement/directory/roleAssignments' })
            @($assign).Count | Should -Be 1
            $assign[0].Body.roleDefinitionId | Should -Be 'roledef-globalreader'
            $assign[0].Body.principalId      | Should -Be 'sp-app-1'
            $assign[0].Body.directoryScopeId | Should -Be '/'

            $out.Result.DirectoryRole | Should -Be 'Global Reader'
        }

        It 'grants Teams on the Teams admin API and assigns the directory role' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                $result = New-MsecApp -KeyVaultName 'kv-test' -Workload Teams -WarningAction SilentlyContinue 6>$null
                [pscustomobject]@{ Result = $result; Calls = @($script:Calls) }
            }

            # Teams is a SEPARATE audience from Graph. Connect-MicrosoftTeams presents a token
            # for the Skype and Teams Tenant Admin API, which carries only the roles granted on
            # THAT resource - so Graph permissions, however broad, buy nothing here.
            $grant = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' -and $_.Body.appRoleId -eq 'role-teams' })
            @($grant).Count | Should -Be 1
            $grant[0].Body.resourceId | Should -Be 'sp-teams'

            # And the directory role, for the same reason Exchange needs one: without it
            # Connect-MicrosoftTeams succeeds and every Get-Cs* call then fails.
            $assign = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/roleManagement/directory/roleAssignments' })
            @($assign).Count | Should -Be 1
            $out.Result.DirectoryRole | Should -Be 'Global Reader'
        }

        It 'takes the directory role under its old Exchange-specific name' {
            # -ExchangeDirectoryRole shipped in 0.2.0. Renaming it outright would break every
            # caller that already passes it, and silently: a bootstrap script would fail at the
            # parameter binder rather than at anything to do with permissions.
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange `
                    -ExchangeDirectoryRole 'Exchange Administrator' -WarningAction SilentlyContinue 6>$null
            }

            $out.DirectoryRole | Should -Be 'Exchange Administrator'
        }

        It 'does not re-assign a directory role the app already holds' {
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                $script:ExistingRoleAssignments = @([pscustomobject]@{ id = 'existing'; principalId = 'sp-app-1'; roleDefinitionId = 'roledef-globalreader' })
                $result = New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange -WarningAction SilentlyContinue 6>$null
                [pscustomobject]@{ Result = $result; Calls = @($script:Calls) }
            }

            # Re-running must be a no-op, like the rest of this command.
            @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/roleManagement/directory/roleAssignments' }).Count | Should -Be 0
            $out.Result.DirectoryRole | Should -Match 'already assigned'
        }

        It 'configures everything else when the caller cannot assign a directory role' {
            $warnings = @()
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                # Creating a role assignment needs Privileged Role Administrator - a higher bar
                # than the rest of this command, so a caller may legitimately not have it.
                $script:RoleAssignDenied = $true
                New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange 6>$null
            } -WarningVariable warnings -WarningAction SilentlyContinue

            # The app roles still landed; only the role assignment failed.
            $out.GrantedNow | Should -Contain 'Office 365 Exchange Online: Exchange.ManageAsApp'
            $out.DirectoryRole | Should -BeNullOrEmpty
            ($warnings -join ' ') | Should -Match 'Privileged Role Administrator'
            ($warnings -join ' ') | Should -Match 'NOT enough'
        }

        It 'skips a workload whose service principal does not exist in the tenant' {
            $warnings = @()
            $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } {
                param($MockText)
                & ([scriptblock]::Create($MockText))
                # A tenant with no Exchange Online. Must not abort the whole bootstrap.
                $script:ExchangeSpMissing = $true
                New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange 6>$null
            } -WarningVariable warnings -WarningAction SilentlyContinue

            $out.UnavailableRoles -join ' ' | Should -Match 'service principal not found'
            ($warnings -join ' ') | Should -Match 'could not be resolved'
            # The Graph roles were still configured.
            @($out.GrantedNow).Count | Should -BeGreaterThan 0
        }
    }
}