tests/MsecWorkloadConnect.Tests.ps1

#Requires -Module Pester
#
# Tests for the Exchange and SharePoint bridges, and the two collection commands behind them.
#
# These reach products msec does NOT otherwise touch, and each has a trap the module's Graph
# commands do not:
#
# * Exchange and PnP take -AccessToken as a plain STRING. Connect-MgGraph is the odd one out
# in wanting a SecureString, so copying that call is wrong here.
# * a SharePoint token's audience is the site HOST, not the tenant - a token for
# contoso.sharepoint.com is rejected by contoso-admin.sharepoint.com.
# * Exchange needs a DIRECTORY ROLE on top of the app role, and reports the difference as a
# plain authorisation failure that names nothing.

$script:HasExo = $null -ne (Get-Module -ListAvailable ExchangeOnlineManagement)
$script:HasPnp = $null -ne (Get-Module -ListAvailable PnP.PowerShell)

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

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

Describe 'Connect-MsecExchangeOnline' -Skip:(-not $script:HasExo) {

    BeforeEach {
        InModuleScope Msec {
            $script:MsecSession = @{
                TenantId = 't'; ClientId = 'client-1'; KeyVaultName = 'kv'; KeyName = 'k'
                ThumbprintBytes = [byte[]](1..20)
                Endpoints = @{ GraphResource = 'https://graph.microsoft.com'; EnvironmentName = 'AzureCloud' }
                Tokens = @{}
            }
        }
    }

    It 'asks for an Exchange token, not a Graph one, and passes it as a plain string' {
        InModuleScope Msec {
            $script:MsecSession.Tokens['https://outlook.office365.com'] = @{
                Token = 'EXO.TOKEN'; ExpiresOn = [DateTimeOffset]::UtcNow.AddMinutes(50)
            }
            Mock Get-MsecAccessToken -MockWith { 'EXO.TOKEN' }
            Mock Connect-ExchangeOnline -MockWith { }

            Connect-MsecExchangeOnline -Organization 'contoso.onmicrosoft.com'

            # Exchange issues its own audience - a Graph token is rejected here.
            Should -Invoke Get-MsecAccessToken -Times 1 -Exactly -ParameterFilter {
                $Resource -eq 'https://outlook.office365.com'
            }
            Should -Invoke Connect-ExchangeOnline -Times 1 -Exactly -ParameterFilter {
                # A plain String, NOT a SecureString - the opposite of Connect-MgGraph.
                $AccessToken -is [string] -and $AccessToken -eq 'EXO.TOKEN' -and
                $Organization -eq 'contoso.onmicrosoft.com' -and $AppId -eq 'client-1'
            }
        }
    }

    It 'refuses a token too close to expiry rather than failing mid-enumeration' {
        InModuleScope Msec {
            $script:MsecSession.Tokens['https://outlook.office365.com'] = @{
                Token = 'x'; ExpiresOn = [DateTimeOffset]::UtcNow.AddMinutes(2)
            }
            Mock Get-MsecAccessToken -MockWith { 'x' }
            Mock Connect-ExchangeOnline -MockWith { }

            { Connect-MsecExchangeOnline -Organization 'c.com' -MinimumMinutes 30 } | Should -Throw '*cannot renew*'
            Should -Invoke Connect-ExchangeOnline -Times 0 -Exactly
        }
    }

    It 'warns when the session is in a cloud this endpoint is wrong for' {
        $warnings = @()
        InModuleScope Msec {
            $script:MsecSession.Endpoints.EnvironmentName = 'AzureChinaCloud'
            $script:MsecSession.Tokens['https://outlook.office365.com'] = @{
                Token = 'x'; ExpiresOn = [DateTimeOffset]::UtcNow.AddMinutes(50)
            }
            Mock Get-MsecAccessToken -MockWith { 'x' }
            Mock Connect-ExchangeOnline -MockWith { }
            Connect-MsecExchangeOnline -Organization 'c.cn'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        ($warnings -join ' ') | Should -Match 'commercial Exchange endpoint'
    }

    It 'throws a clear error when not connected' {
        InModuleScope Msec {
            $script:MsecSession = $null
            { Connect-MsecExchangeOnline -Organization 'c.com' } | Should -Throw '*Connect-Msec*'
        }
    }
}

Describe 'Connect-MsecSharePointOnline' -Skip:(-not $script:HasPnp) {

    BeforeEach {
        InModuleScope Msec {
            $script:MsecSession = @{
                TenantId = 't'; ClientId = 'client-1'; KeyVaultName = 'kv'; KeyName = 'k'
                ThumbprintBytes = [byte[]](1..20)
                Endpoints = @{ GraphResource = 'https://graph.microsoft.com'; EnvironmentName = 'AzureCloud' }
                Tokens = @{}
            }
        }
    }

    It 'derives the token audience from the site HOST, not the full url' {
        InModuleScope Msec {
            $script:MsecSession.Tokens['https://contoso.sharepoint.com'] = @{
                Token = 'SP.TOKEN'; ExpiresOn = [DateTimeOffset]::UtcNow.AddMinutes(50)
            }
            Mock Get-MsecAccessToken -MockWith { 'SP.TOKEN' }
            Mock Connect-PnPOnline -MockWith { }

            Connect-MsecSharePointOnline -Url 'https://contoso.sharepoint.com/sites/finance'

            # The path must not reach the audience, or SharePoint rejects the token.
            Should -Invoke Get-MsecAccessToken -Times 1 -Exactly -ParameterFilter {
                $Resource -eq 'https://contoso.sharepoint.com'
            }
            Should -Invoke Connect-PnPOnline -Times 1 -Exactly -ParameterFilter {
                $Url -eq 'https://contoso.sharepoint.com/sites/finance' -and
                $AccessToken -is [string] -and $AccessToken -eq 'SP.TOKEN'
            }
        }
    }

    It 'treats the admin host as a different audience' {
        InModuleScope Msec {
            $script:MsecSession.Tokens['https://contoso-admin.sharepoint.com'] = @{
                Token = 'ADMIN.TOKEN'; ExpiresOn = [DateTimeOffset]::UtcNow.AddMinutes(50)
            }
            Mock Get-MsecAccessToken -MockWith { 'ADMIN.TOKEN' }
            Mock Connect-PnPOnline -MockWith { }

            Connect-MsecSharePointOnline -Url 'https://contoso-admin.sharepoint.com'

            # A tenant-host token does not work against the admin host, and vice versa.
            Should -Invoke Get-MsecAccessToken -Times 1 -Exactly -ParameterFilter {
                $Resource -eq 'https://contoso-admin.sharepoint.com'
            }
        }
    }

    It 'rejects a url that is not absolute' {
        InModuleScope Msec {
            Mock Get-MsecAccessToken -MockWith { 'x' }
            Mock Connect-PnPOnline -MockWith { }
            { Connect-MsecSharePointOnline -Url 'contoso.sharepoint.com' } | Should -Throw '*absolute URL*'
        }
    }
}

Describe 'Get-MsecExchangeMailboxPermission' -Skip:(-not $script:HasExo) {

    It 'drops NT AUTHORITY\SELF, which every mailbox grants itself' {
        $rows = InModuleScope Msec {
            Mock Get-EXOMailbox -MockWith {
                [pscustomobject]@{ DisplayName = 'Finance'; UserPrincipalName = 'finance@x.com'
                                   PrimarySmtpAddress = 'finance@x.com'; RecipientTypeDetails = 'SharedMailbox' }
            }
            Mock Get-EXOMailboxPermission -MockWith {
                [pscustomobject]@{ User = 'NT AUTHORITY\SELF'; AccessRights = @('FullAccess'); IsInherited = $false; Deny = $false }
                [pscustomobject]@{ User = 'ada@x.com';         AccessRights = @('FullAccess'); IsInherited = $false; Deny = $false }
            }
            Get-MsecExchangeMailboxPermission
        }

        # Including SELF would put a meaningless finding on every mailbox.
        @($rows).Count | Should -Be 1
        $rows.Grantee  | Should -Be 'ada@x.com'
    }

    It 'says "None" for a mailbox nobody else can open, and "Unreadable" when it could not look' {
        $rows = InModuleScope Msec {
            Mock Get-EXOMailbox -MockWith {
                [pscustomobject]@{ DisplayName = 'Private'; UserPrincipalName = 'p@x.com'; PrimarySmtpAddress = 'p@x.com'; RecipientTypeDetails = 'SharedMailbox' }
                [pscustomobject]@{ DisplayName = 'Denied';  UserPrincipalName = 'd@x.com'; PrimarySmtpAddress = 'd@x.com'; RecipientTypeDetails = 'SharedMailbox' }
            }
            Mock Get-EXOMailboxPermission -ParameterFilter { $Identity -eq 'p@x.com' } -MockWith {
                [pscustomobject]@{ User = 'NT AUTHORITY\SELF'; AccessRights = @('FullAccess'); IsInherited = $false; Deny = $false }
            }
            Mock Get-EXOMailboxPermission -ParameterFilter { $Identity -eq 'd@x.com' } -MockWith { throw 'Access denied' }
            Get-MsecExchangeMailboxPermission -WarningAction SilentlyContinue
        }

        # Two different answers, and only one of them is good news.
        ($rows | Where-Object MailboxUserPrincipalName -eq 'p@x.com').Grantee | Should -Be 'None'
        ($rows | Where-Object MailboxUserPrincipalName -eq 'd@x.com').Grantee | Should -Be 'Unreadable'
    }

    It 'names the directory role requirement when listing mailboxes is refused' {
        InModuleScope Msec {
            Mock Get-EXOMailbox -MockWith { throw 'The term is not recognized as an authorized operation' }
            # Exchange.ManageAsApp alone is NOT enough, and Exchange does not say so.
            { Get-MsecExchangeMailboxPermission } | Should -Throw '*directory role*'
        }
    }
}

Describe 'Get-MsecSharePointSiteUser' -Skip:(-not $script:HasPnp) {

    BeforeEach {
        InModuleScope Msec {
            $script:MsecSession = @{
                TenantId = 't'; ClientId = 'c'; KeyVaultName = 'kv'; KeyName = 'k'
                ThumbprintBytes = [byte[]](1..20)
                Endpoints = @{ GraphResource = 'https://graph.microsoft.com'; EnvironmentName = 'AzureCloud' }
                Tokens = @{}
            }
            # An ambient PnP connection, which the tests that omit -Url rely on. Individual
            # tests override it where the point is that there ISN'T one.
            Mock Get-PnPConnection -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/QA' } }
        }
    }


    It 'connects itself when given -Url, without moving the ambient session' {
        InModuleScope Msec {
            Mock Connect-MsecSharePointOnline -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/QA'; Tag = 'explicit-connection' } }
            Mock Get-PnPConnection -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/OTHER' } }
            Mock Get-PnPWeb -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/QA' } }
            Mock Get-PnPGroup -MockWith { $null }

            Get-MsecSharePointSiteUser -Url 'https://x.sharepoint.com/sites/QA' -WarningAction SilentlyContinue | Out-Null

            # It asked for a connection object rather than making one ambient - PnP keeps ONE
            # global connection, so connecting normally would leave the caller pointed at a
            # different site than they were on.
            Should -Invoke Connect-MsecSharePointOnline -Times 1 -Exactly -ParameterFilter { $PassThru }

            # THE REST OF THIS PATH IS NOT MOCKABLE, and pretending otherwise would be worse
            # than not testing it. PnP types -Connection as PnPConnection, so a stand-in
            # PSCustomObject fails PARAMETER BINDING before the mock is ever reached - which
            # this command then catches and turns into a warning. So a test asserting the PnP
            # calls happen would fail whether the code is right or wrong.
            #
            # Verified against a live tenant instead: Get-MsecSharePointSiteUser -Url <siteB>
            # returned siteB's members while Get-PnPConnection still pointed at siteA.
        }
    }

    It 'uses the ambient connection when no -Url is given' {
        InModuleScope Msec {
            Mock Connect-MsecSharePointOnline -MockWith { throw 'should not connect' }
            Mock Get-PnPConnection -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/QA' } }
            Mock Get-PnPWeb -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/QA' } }
            Mock Get-PnPGroup -MockWith { $null }

            Get-MsecSharePointSiteUser -WarningAction SilentlyContinue | Out-Null

            Should -Invoke Connect-MsecSharePointOnline -Times 0 -Exactly
            # No -Connection passed, so PnP falls back to the ambient one.
            Should -Invoke Get-PnPGroup -Times 1 -ParameterFilter { -not $Connection }
        }
    }

    It 'says what to do when there is neither a -Url nor a connection' {
        InModuleScope Msec {
            Mock Get-PnPConnection -MockWith { $null }
            Mock Connect-MsecSharePointOnline -MockWith { }
            { Get-MsecSharePointSiteUser } | Should -Throw '*pass -Url*'
        }
    }
    It 'types a direct member the same way as one reached through a group' {
        # The two paths are typed by different systems - SharePoint says 'User', Graph says
        # '#microsoft.graph.user'. Left alone the same person reads as 'User' or 'user'
        # depending on how they got access, and a filter on either silently misses the other.
        $rows = InModuleScope Msec {
            Mock Get-PnPWeb -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/QA' } }
            Mock Get-PnPGroup -ParameterFilter { $AssociatedOwnerGroup } -MockWith {
                [pscustomobject]@{ Title = 'QA Owners'; Users = @(
                    [pscustomobject]@{ Title = 'Direct Person'; Email = 'direct@x.com'; PrincipalType = 'User'; LoginName = 'i:0#.f|membership|direct@x.com' }
                    [pscustomobject]@{ Title = 'QA';            PrincipalType = 'SecurityGroup'; LoginName = 'c:0t.c|tenant|11111111-2222-3333-4444-555555555555' }
                ) }
            }
            Mock Get-PnPGroup -ParameterFilter { $AssociatedMemberGroup } -MockWith { $null }
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u9'; displayName = 'Via Group'; userPrincipalName = 'via@x.com' }
            }
            Get-MsecSharePointSiteUser -WarningAction SilentlyContinue
        }

        @($rows).Count | Should -Be 2
        # Both lowercase 'user', whichever path produced them.
        @($rows | ForEach-Object { $_.PrincipalType } | Select-Object -Unique) | Should -Be @('user')

        # ViaGroup is what says where to remove the access.
        ($rows | Where-Object Name -eq 'Direct Person').ViaGroup | Should -BeNullOrEmpty
        ($rows | Where-Object Name -eq 'Via Group').ViaGroup     | Should -Be 'QA'
    }

    It 'does not claim a site has no group when the call simply failed' {
        $warnings = @()
        InModuleScope Msec {
            Mock Get-PnPWeb -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/QA' } }
            # A real splatting bug in this command produced exactly this shape, and the old
            # message asserted "this site has no associated Owner group" - which sent a code
            # defect undiagnosed as a property of the tenant.
            Mock Get-PnPGroup -MockWith { throw 'Cannot convert System.Object[] to GroupPipeBind' }
            Get-MsecSharePointSiteUser
        } -WarningVariable warnings -WarningAction SilentlyContinue

        ($warnings -join ' ') | Should -Match 'Could not read the associated'
        ($warnings -join ' ') | Should -Match 'may genuinely not have one, or the call failed'
    }

    It 'warns loudly when there is no msec session to expand groups with' {
        $warnings = @()
        $rows = InModuleScope Msec {
            # Without the second session every group is unresolved - which is exactly what a
            # DELETED group looks like. The output would read as a tenant full of orphans.
            $script:MsecSession = $null
            Mock Get-PnPWeb -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/QA' } }
            Mock Get-PnPGroup -ParameterFilter { $AssociatedOwnerGroup } -MockWith {
                [pscustomobject]@{ Title = 'QA Owners'; Users = @(
                    [pscustomobject]@{ Title = 'QA'; PrincipalType = 'SecurityGroup'; LoginName = 'c:0t.c|tenant|11111111-2222-3333-4444-555555555555' }
                ) }
            }
            Mock Get-PnPGroup -ParameterFilter { $AssociatedMemberGroup } -MockWith { $null }
            Get-MsecSharePointSiteUser
        } -WarningVariable warnings -WarningAction SilentlyContinue

        ($warnings -join ' ') | Should -Match 'will NOT be expanded'
        # And the row says which of the three reasons it is.
        ($rows | Where-Object PrincipalType -eq 'group').UnresolvedReason | Should -Be 'No msec session'
    }

    It 'keeps a group it could not read, rather than dropping the access' {
        $rows = InModuleScope Msec {
            Mock Get-PnPWeb -MockWith { [pscustomobject]@{ Url = 'https://x.sharepoint.com/sites/QA' } }
            Mock Get-PnPGroup -ParameterFilter { $AssociatedOwnerGroup } -MockWith {
                [pscustomobject]@{ Title = 'Deleted Group'; Users = @(
                    [pscustomobject]@{ Title = 'Deleted Group'; PrincipalType = 'SecurityGroup'; LoginName = 'c:0t.c|tenant|99999999-2222-3333-4444-555555555555' }
                ) }
            }
            Mock Get-PnPGroup -ParameterFilter { $AssociatedMemberGroup } -MockWith { $null }
            # SharePoint keeps granting access through a group Entra no longer has.
            Mock Invoke-MsecGraphRequest -MockWith { throw 'Request_ResourceNotFound' }
            Get-MsecSharePointSiteUser -WarningAction SilentlyContinue
        }

        @($rows).Count | Should -Be 1
        $rows.IsResolved       | Should -BeFalse
        $rows.UnresolvedReason | Should -Be 'Group could not be read from Entra'
        # Dropping it would report the site as having fewer owners than it does.
        $rows.Name             | Should -Be 'Deleted Group'
    }
}

Describe 'Get-MsecSharePointSite' {

    BeforeEach {
        InModuleScope Msec {
            $script:MsecSession = @{
                TenantId = 't'; ClientId = 'c'; KeyVaultName = 'kv'; KeyName = 'k'
                ThumbprintBytes = [byte[]](1..20); Tokens = @{}
                Endpoints = @{ GraphResource = 'https://graph.microsoft.com'; EnvironmentName = 'AzureCloud' }
            }
        }
    }

    It 'excludes app containers, which are most of what Graph calls a site' {
        $sites = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ displayName = 'Developers'; webUrl = 'https://x.sharepoint.com/sites/Developers'; id = '1' }
                [pscustomobject]@{ displayName = 'Team chat';  webUrl = 'https://x.sharepoint.com/teams/Chat';       id = '2' }
                # Loop / Designer backing storage - one per artefact. On a real tenant these
                # were 286 of 432 results, and running a site access review across them is
                # hundreds of wasted calls.
                [pscustomobject]@{ displayName = 'Loop thing'; webUrl = 'https://x.sharepoint.com/contentstorage/abc'; id = '3' }
                [pscustomobject]@{ displayName = 'Jane';       webUrl = 'https://x-my.sharepoint.com/personal/jane_x_com'; id = '4' }
                [pscustomobject]@{ displayName = 'Root';       webUrl = 'https://x.sharepoint.com'; id = '5'; root = @{} }
            }
            Get-MsecSharePointSite
        }

        @($sites | ForEach-Object { $_.DisplayName } | Sort-Object) | Should -Be @('Developers', 'Root', 'Team chat')
        @($sites | Where-Object SiteType -eq 'AppContainer').Count | Should -Be 0
        @($sites | Where-Object SiteType -eq 'Personal').Count     | Should -Be 0
    }

    It 'classifies everything under -All, so the denominator is visible' {
        $sites = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ displayName = 'A'; webUrl = 'https://x.sharepoint.com/sites/A'; id = '1' }
                [pscustomobject]@{ displayName = 'B'; webUrl = 'https://x.sharepoint.com/contentstorage/b'; id = '2' }
                [pscustomobject]@{ displayName = 'C'; webUrl = 'https://x-my.sharepoint.com/personal/c'; id = '3' }
            }
            Get-MsecSharePointSite -All
        }

        # "146 of 432 are real sites" is a finding; "146 sites" hides that the tenant holds
        # three times as many objects Graph is willing to call a site.
        @($sites).Count | Should -Be 3
        @($sites | ForEach-Object { $_.SiteType } | Sort-Object) | Should -Be @('AppContainer', 'Personal', 'SiteCollection')
    }

    It 'names the GRAPH permission on a 403, not the SharePoint one' {
        InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' }
            # Sites.Read.All exists on both resources with the same name, and having the
            # SharePoint one is exactly the state someone will be in when this fails.
            { Get-MsecSharePointSite } | Should -Throw '*MICROSOFT GRAPH*'
        }
    }

    It 'throws a clear error when not connected' {
        InModuleScope Msec {
            $script:MsecSession = $null
            { Get-MsecSharePointSite } | Should -Throw '*Connect-Msec*'
        }
    }
}