tests/Get-MsecTeamsPolicy.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecTeamsPolicy and Connect-MsecTeams. # # Teams is the third workload msec reaches through someone else's module, and it has a wrinkle # the other two do not: Connect-MicrosoftTeams -AccessTokens takes an ARRAY of two tokens, for # Microsoft Graph and for the 'Skype and Teams Tenant Admin API'. They are separate audiences # with separate app roles, and passing only the Graph one fails in a way that reads as a # permission problem rather than a missing token. # # The other thing worth pinning: a policy area that cannot be read must produce a row saying so. # A missing federation configuration reads as a tenant with no external access, which is the # opposite of the truth. $script:HasTeams = $null -ne (Get-Module -ListAvailable MicrosoftTeams) BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Connect-MsecTeams' -Skip:(-not $script:HasTeams) { 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 'passes two tokens, Graph first then the Teams admin API' { InModuleScope Msec { foreach ($r in 'https://graph.microsoft.com', '48ac35b8-9aa8-4d74-927d-1f4a14a0b239') { $script:MsecSession.Tokens[$r] = @{ Token = "tok-$r"; ExpiresOn = [DateTimeOffset]::UtcNow.AddMinutes(50) } } Mock Get-MsecAccessToken -MockWith { "tok-$Resource" } Mock Connect-MicrosoftTeams -MockWith { } Connect-MsecTeams # Both audiences, not just Graph. Should -Invoke Get-MsecAccessToken -Times 1 -Exactly -ParameterFilter { $Resource -eq 'https://graph.microsoft.com' } Should -Invoke Get-MsecAccessToken -Times 1 -Exactly -ParameterFilter { $Resource -eq '48ac35b8-9aa8-4d74-927d-1f4a14a0b239' } # Order matters - the module does not identify them by inspection. Should -Invoke Connect-MicrosoftTeams -Times 1 -Exactly -ParameterFilter { $AccessTokens.Count -eq 2 -and $AccessTokens[0] -eq 'tok-https://graph.microsoft.com' -and $AccessTokens[1] -eq 'tok-48ac35b8-9aa8-4d74-927d-1f4a14a0b239' } } } It 'names the Teams admin API when its token cannot be minted' { InModuleScope Msec { Mock Connect-MicrosoftTeams -MockWith { } Mock Get-MsecAccessToken -MockWith { if ($Resource -eq '48ac35b8-9aa8-4d74-927d-1f4a14a0b239') { throw 'AADSTS500011: resource principal not found' } 'graph-token' } # Having Graph permissions and not the Teams resource ones is the state someone # will actually be in, so the message has to distinguish them. { Connect-MsecTeams } | Should -Throw '*Skype and Teams Tenant Admin API*' } } It 'borrows the Az session under -AsCurrentUser, so no interactive sign-in happens' { InModuleScope Msec { # The reason this mode exists: Connect-MicrosoftTeams's browser flow calls into # kernel32.dll and dies on macOS, and device code flow is refused by Conditional # Access policies requiring a compliant device. The Az session already cleared CA. $script:MsecSession = $null Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Environment = [pscustomobject]@{ Name = 'AzureCloud'; ExtendedProperties = @{} } } } Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = "user-token-for-$ResourceUrl"; ExpiresOn = [DateTimeOffset]::UtcNow.AddHours(1) } } Mock Get-MsecAccessToken -MockWith { throw 'the app path must not be used here' } Mock Connect-MicrosoftTeams -MockWith { } Connect-MsecTeams -AsCurrentUser Should -Invoke Connect-MicrosoftTeams -Times 1 -Exactly -ParameterFilter { $AccessTokens.Count -eq 2 -and $AccessTokens[0] -eq 'user-token-for-https://graph.microsoft.com' -and $AccessTokens[1] -eq 'user-token-for-48ac35b8-9aa8-4d74-927d-1f4a14a0b239' } } } It 'unwraps a SecureString token, because Az.Accounts 5 returns one' { InModuleScope Msec { $script:MsecSession = $null Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Environment = [pscustomobject]@{ Name = 'AzureCloud'; ExtendedProperties = @{} } } } Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = (ConvertTo-SecureString 'secret-token' -AsPlainText -Force) ExpiresOn = [DateTimeOffset]::UtcNow.AddHours(1) } } Mock Connect-MicrosoftTeams -MockWith { } Connect-MsecTeams -AsCurrentUser # -AccessTokens is String[]. Handing it the SecureString gives an authentication # failure with 'System.Security.SecureString' where the token should be - which # reads as a permission problem. Should -Invoke Connect-MicrosoftTeams -Times 1 -Exactly -ParameterFilter { $AccessTokens[0] -eq 'secret-token' } } } It 'refuses -AsCurrentUser with no Azure context' { InModuleScope Msec { $script:MsecSession = $null Mock Get-AzContext -MockWith { $null } { Connect-MsecTeams -AsCurrentUser } | Should -Throw '*Connect-AzAccount*' } } It 'throws a clear error when not connected' { InModuleScope Msec { $script:MsecSession = $null { Connect-MsecTeams } | Should -Throw '*Connect-Msec*' } } } Describe 'Get-MsecTeamsPolicy' -Skip:(-not $script:HasTeams) { BeforeEach { InModuleScope Msec { $script:MsecSession = $null # Module state outlives a Describe: the -AsCurrentUser tests above set this, and # without the reset it silently suppresses the self-connect being tested here. $script:MsecTeamsAsCurrentUser = $null } } It 'flattens to one row per setting, keeping only the security-relevant ones' { $rows = InModuleScope Msec { Mock Get-CsTenantFederationConfiguration -MockWith { [pscustomobject]@{ Identity = 'Global' AllowFederatedUsers = $true AllowPublicUsers = $false AllowedDomains = @('partner.com', 'vendor.com') # Not a security control - must not appear in the default projection. TreatDiscoveredPartnersAsUnverified = $false } } Get-MsecTeamsPolicy -PolicyType Federation } # A meeting policy carries ~80 properties; returning whole objects hides the handful # that matter. @($rows | ForEach-Object { $_.Setting }) | Should -Contain 'AllowFederatedUsers' @($rows | ForEach-Object { $_.Setting }) | Should -Not -Contain 'TreatDiscoveredPartnersAsUnverified' # Collections are joined, not rendered as System.Object[]. ($rows | Where-Object Setting -eq 'AllowedDomains').Value | Should -Be 'partner.com; vendor.com' ($rows | Where-Object Setting -eq 'AllowFederatedUsers').IsGlobal | Should -BeTrue } It 'returns everything under -All, so the projection can be checked' { $rows = InModuleScope Msec { Mock Get-CsTenantFederationConfiguration -MockWith { [pscustomobject]@{ Identity = 'Global'; AllowFederatedUsers = $true; TreatDiscoveredPartnersAsUnverified = $false } } Get-MsecTeamsPolicy -PolicyType Federation -All } # Which settings count as security controls is this command's judgement, so there has # to be a way to see what it left out. @($rows | ForEach-Object { $_.Setting }) | Should -Contain 'TreatDiscoveredPartnersAsUnverified' } It 'marks the Global policy, because that is the one a user gets by default' { $rows = InModuleScope Msec { Mock Get-CsTeamsMeetingPolicy -MockWith { [pscustomobject]@{ Identity = 'Global'; AllowAnonymousUsersToJoinMeeting = $true } [pscustomobject]@{ Identity = 'Tag:Restricted'; AllowAnonymousUsersToJoinMeeting = $false } } Get-MsecTeamsPolicy -PolicyType Meeting } # A permissive Global is a tenant-wide finding; a permissive custom policy may apply to # nobody at all. ($rows | Where-Object PolicyName -eq 'Global').IsGlobal | Should -BeTrue ($rows | Where-Object PolicyName -eq 'Restricted').IsGlobal | Should -BeFalse # The 'Tag:' prefix is noise in a table. @($rows | ForEach-Object { $_.PolicyName }) | Should -Not -Contain 'Tag:Restricted' } It 'reports a policy area it could not read, rather than omitting it' { $warnings = @() $rows = InModuleScope Msec { Mock Get-CsTenantFederationConfiguration -MockWith { throw 'Access Denied' } Get-MsecTeamsPolicy -PolicyType Federation } -WarningVariable warnings -WarningAction SilentlyContinue # Silently omitting it would read as a tenant with no external access configured - the # opposite of the truth. @($rows).Count | Should -Be 1 $rows.PolicyName | Should -Be 'Unreadable' ($warnings -join ' ') | Should -Match 'directory role' } It 'skips a setting the installed module version does not carry' { $rows = InModuleScope Msec { # The property set differs between module versions. Asking for an absent one would # emit a row of nulls that reads as "configured off". Mock Get-CsTeamsMeetingPolicy -MockWith { [pscustomobject]@{ Identity = 'Global'; AllowAnonymousUsersToJoinMeeting = $true } } Get-MsecTeamsPolicy -PolicyType Meeting } @($rows).Count | Should -Be 1 @($rows | Where-Object { $null -eq $_.Value -and $_.PolicyName -ne 'Unreadable' }).Count | Should -Be 0 } It 'reads the files policy, which is a different surface from the storage switches' { $rows = InModuleScope Msec { Mock Get-CsTeamsFilesPolicy -MockWith { [pscustomobject]@{ Identity = 'Global' FileSharingInChatswithExternalUsers = 'Enabled' DefaultFileUploadAppId = 'Dropbox' NativeFileEntryPoints = 'Enabled' } } Get-MsecTeamsPolicy -PolicyType Files } # AllowDropBox and friends under Client say which providers APPEAR in Teams. This says # whether a file can leave the tenant through a chat with someone outside it - a tenant # can have every third-party provider off and still allow that. ($rows | Where-Object Setting -eq 'FileSharingInChatswithExternalUsers').Value | Should -Be 'Enabled' ($rows | Where-Object Setting -eq 'FileSharingInChatswithExternalUsers').IsGlobal | Should -BeTrue ($rows | Where-Object Setting -eq 'DefaultFileUploadAppId').Value | Should -Be 'Dropbox' } It 'signs in to Teams itself when there is an msec session' { InModuleScope Msec { $script:MsecSession = @{ TenantId = 't'; ClientId = 'c' } Mock Connect-MsecTeams -MockWith { } Mock Get-CsTenantFederationConfiguration -MockWith { [pscustomobject]@{ Identity = 'Global'; AllowFederatedUsers = $true } } Get-MsecTeamsPolicy -PolicyType Federation | Out-Null # Without this the command ran straight into five failed Get-Cs* calls, each # warning about a directory role, when the real answer was that nobody had # connected. Every other command in this module is one call after Connect-Msec. Should -Invoke Connect-MsecTeams -Times 1 -Exactly } } It 'does not replace a session the caller deliberately opened as themselves' { InModuleScope Msec { $script:MsecSession = @{ TenantId = 't'; ClientId = 'c' } $script:MsecTeamsAsCurrentUser = $true Mock Connect-MsecTeams -MockWith { } Mock Get-CsTenantFederationConfiguration -MockWith { [pscustomobject]@{ Identity = 'Global'; AllowFederatedUsers = $true } } Get-MsecTeamsPolicy -PolicyType Federation | Out-Null # Reconnecting as the app would swap a session that can write for one that cannot, # and the caller's next Set-Cs* would fail on rights they do have. Should -Invoke Connect-MsecTeams -Times 0 -Exactly } } It 'throws when there is neither a Teams session nor an msec session to make one' { InModuleScope Msec { # Get-Command finding the cmdlet only proves MicrosoftTeams is INSTALLED - it says # nothing about whether anyone connected. That was the original guard, and it let # an unconnected run through. Mock Get-Command -MockWith { $null } -ParameterFilter { $Name -eq 'Get-CsTenantFederationConfiguration' } { Get-MsecTeamsPolicy } | Should -Throw '*Connect-Msec*' } } It 'does not blame the directory role for a failure that is not about permissions' { $warnings = @() InModuleScope Msec { Mock Get-CsTenantFederationConfiguration -MockWith { throw 'The remote server returned 503' } Get-MsecTeamsPolicy -PolicyType Federation } -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null # The hint is a guess. Offered against an authorisation error it is usually right; # offered against anything else it sends the reader to check a role they already have. ($warnings -join ' ') | Should -Not -Match 'directory role|DIRECTORY ROLE' ($warnings -join ' ') | Should -Match '503' } } |