tests/MsecContextStore.Tests.ps1
|
#Requires -Module Pester # # Tests for Save- / Set- / Remove-MsecContext and the store behind them. # # The traps: # # THE SESSION DOES NOT CARRY THE CERTIFICATE NAME. It holds the Key Vault KEY name resolved # from the certificate's metadata - a different thing, and not what Connect-Msec takes. # Saving $session.CertificateName silently recorded an empty string, and the reconnect then # failed on parameter binding. The tenant profile is where that name lives. # # WORKLOAD SESSIONS CANNOT BE RE-POINTED, ONLY TORN DOWN. Left alone across a tenant switch # they keep working against the tenant you left, which is worse than being disconnected. # # A FAILED RECONNECT MUST NOT UNDO THE SWITCH. The context change is what was asked for. # # AZURE NEEDS A SEPARATE SIGN-IN PER CLOUD, and Set-AzContext -Subscription/-Tenant resolves # the subscription INSIDE THE CURRENT ACCOUNT. Signed in to one cloud, switching to a context # in another failed with "Please provide a valid tenant or a valid subscription" while the # target context sat in Az's own cache, fully usable. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop $script:CacheDir = Join-Path ([System.IO.Path]::GetTempPath()) ("msec-ctx-" + [guid]::NewGuid()) $env:MSEC_CACHE_DIR = $script:CacheDir $script:StubbedAz = @() foreach ($c in 'Get-AzContext', 'Set-AzContext') { if (-not (Get-Command $c -ErrorAction SilentlyContinue)) { Set-Item "function:global:$c" -Value { [CmdletBinding()] param($Subscription, $Tenant, $Context, $Scope, [switch]$ListAvailable) } -Force $script:StubbedAz += $c } } # Pester's Mock needs the command to EXIST. The workload disconnects live in modules that # are not dependencies of msec, so on a runner without them the mock fails to build and # the test reads as a bug in the code rather than a missing module. foreach ($c in 'Disconnect-ExchangeOnline', 'Disconnect-MicrosoftTeams', 'Disconnect-PnPOnline') { if (-not (Get-Command $c -ErrorAction SilentlyContinue)) { Set-Item "function:global:$c" -Value { [CmdletBinding()] param([switch]$Confirm) } -Force $script:StubbedAz += $c } } # DISCOVERABLE IS NOT USABLE, and a presence check is not enough for the Graph SDK. On the # ubuntu and macos runners Disconnect-MgGraph RESOLVES while its backing assembly # (Microsoft.Graph.Authentication.Core) cannot be loaded, so a presence check skips the # stub and Pester's Mock then dies building CommandMetadata - # "Cannot convert value Disconnect-MgGraph to type CommandMetadata" # which points at the mock rather than at the half-installed module behind it. Same probe # Mock itself performs: build the metadata, and stub when that fails. Identical to the one # in Connect-MsecAdmin.Tests.ps1 and Get-MsecContext.Tests.ps1. $usable = $false $found = Get-Command 'Disconnect-MgGraph' -ErrorAction SilentlyContinue if ($found) { try { $null = [System.Management.Automation.CommandMetadata]::new($found); $usable = $true } catch { $usable = $false } } if (-not $usable) { Set-Item 'function:global:Disconnect-MgGraph' -Value { [CmdletBinding()] param() } -Force $script:StubbedAz += 'Disconnect-MgGraph' } } AfterAll { Remove-Item $script:CacheDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item Env:MSEC_CACHE_DIR -ErrorAction SilentlyContinue foreach ($c in $script:StubbedAz) { Remove-Item "function:$c" -ErrorAction SilentlyContinue } Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Save-MsecContext' { BeforeEach { InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Environment = [pscustomobject]@{ Name = 'AzureCloud' } } } $script:MsecSession = @{ TenantId = 'tenant-1'; ClientId = 'cid'; KeyVaultName = 'kv' KeyName = 'msec-app-key' } $script:MsecAdminSession = $null } } It 'takes the certificate name from the tenant profile, not from the session' { # REGRESSION. The session has KeyName, not CertificateName; reading the latter saved an # empty string and Set-MsecContext then failed binding -CertificateName. $saved = InModuleScope Msec { Mock Get-MsecTenantProfile -MockWith { [pscustomobject]@{ CertificateName = 'from-profile'; KeyVaultName = 'kv'; ClientId = 'cid' } } Save-MsecContext -Name 'ctx-cert' -Force } $saved.CertificateName | Should -Be 'from-profile' $saved.CertificateName | Should -Not -BeNullOrEmpty } It 'records no admin account when there is no write session, which means "does not write"' { $saved = InModuleScope Msec { Mock Get-MsecTenantProfile -MockWith { $null } Save-MsecContext -Name 'ctx-noadmin' -Force } $saved.AdminAccount | Should -BeNullOrEmpty } It 'captures the live write session account by default' { $saved = InModuleScope Msec { Mock Get-MsecTenantProfile -MockWith { $null } $script:MsecAdminSession = [pscustomobject]@{ Account = 'admin@contoso.com'; TenantId = 'tenant-1' } Save-MsecContext -Name 'ctx-admin' -Force } $saved.AdminAccount | Should -Be 'admin@contoso.com' } It 'refuses to save without an app session, naming what to run' { InModuleScope Msec { $script:MsecSession = $null { Save-MsecContext -Name 'nope' -Force } | Should -Throw '*Connect-Msec -KeyVaultName*' } } It 'round-trips through the store and can be removed' { $r = InModuleScope Msec { Mock Get-MsecTenantProfile -MockWith { $null } Save-MsecContext -Name 'ctx-rt' -Force | Out-Null $listed = @(Get-MsecContext -List | Where-Object Name -eq 'ctx-rt') Remove-MsecContext -Name 'ctx-rt' -Confirm:$false [pscustomobject]@{ Listed = $listed.Count After = @(Get-MsecContext -List | Where-Object Name -eq 'ctx-rt').Count } } $r.Listed | Should -Be 1 $r.After | Should -Be 0 } } Describe 'Set-MsecContext' { It 'throws, naming how to list them, for a context that does not exist' { InModuleScope Msec { { Set-MsecContext -Name 'no-such-context' -Confirm:$false } | Should -Throw '*Get-MsecContext -List*' } } It 'selects a cached context rather than resolving inside the current account' { # THE BUG THIS PREVENTS. Set-AzContext -Subscription/-Tenant resolves the subscription # inside the CURRENT account, so switching from a context in one cloud to a context in # another failed - "Please provide a valid tenant or a valid subscription" - while the # target context was already in Az's cache and perfectly usable. A cached context # carries its own account and environment, so selecting it crosses clouds. InModuleScope Msec { Mock Get-AzContext -ParameterFilter { $ListAvailable } -MockWith { @([pscustomobject]@{ Name = 'DEV' Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-dev'; Name = 'DEV' } Tenant = [pscustomobject]@{ Id = 'tenant-dev' } Environment = [pscustomobject]@{ Name = 'AzureCloud' } }) } Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.cn' } Subscription = [pscustomobject]@{ Id = 'sub-cn'; Name = 'CHINA' } Environment = [pscustomobject]@{ Name = 'AzureChinaCloud' } } } Mock Set-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Write-MsecContextStore @{ dev = [pscustomobject]@{ Name = 'dev'; SubscriptionId = 'sub-dev'; Subscription = 'DEV' TenantId = 'tenant-dev'; Environment = 'AzureCloud' KeyVaultName = 'kv'; ClientId = 'cid'; CertificateName = 'cert' } } | Out-Null Set-MsecContext -Name 'dev' -Confirm:$false -KeepWorkload | Out-Null # ASSERTED ON WHICH PARAMETER SET WAS USED, not on the object handed over. # -Context is typed [PSAzureContext], so a [pscustomobject] stand-in is COERCED # into that type on binding and the properties set here do not survive - an # assertion on $Context.Name tests the coercion, not the code. Should -Invoke Set-AzContext -Times 1 -Exactly -ParameterFilter { $null -ne $Context } Should -Invoke Set-AzContext -Times 0 -ParameterFilter { $null -ne $Subscription } } } It 'names the cloud and the sign-in needed when the switch fails across clouds' { # The Az error never mentions the cloud, which is the entire explanation. $msg = InModuleScope Msec { Mock Get-AzContext -ParameterFilter { $ListAvailable } -MockWith { @() } Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.cn' } Subscription = [pscustomobject]@{ Id = 'sub-cn'; Name = 'CHINA' } Environment = [pscustomobject]@{ Name = 'AzureChinaCloud' } } } Mock Set-AzContext -MockWith { throw 'Please provide a valid tenant or a valid subscription.' } Write-MsecContextStore @{ dev = [pscustomobject]@{ Name = 'dev'; SubscriptionId = 'sub-dev'; Subscription = 'DEV' TenantId = 'tenant-dev'; Environment = 'AzureCloud' KeyVaultName = 'kv'; ClientId = 'cid'; CertificateName = 'cert' } } | Out-Null try { Set-MsecContext -Name 'dev' -Confirm:$false -KeepWorkload | Out-Null } catch { $_.Exception.Message } } $msg | Should -BeLike '*AzureCloud cloud*' $msg | Should -BeLike '*AzureChinaCloud*' $msg | Should -BeLike '*Connect-AzAccount -Environment AzureCloud*' } It 'says nothing about clouds for a context saved before the cloud was recorded' { # Older saved contexts have no Environment. Guessing one would be worse than silence. $msg = InModuleScope Msec { Mock Get-AzContext -ParameterFilter { $ListAvailable } -MockWith { @() } Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Environment = [pscustomobject]@{ Name = 'AzureCloud' } } } Mock Set-AzContext -MockWith { throw 'Please provide a valid tenant or a valid subscription.' } Write-MsecContextStore @{ old = [pscustomobject]@{ Name = 'old'; SubscriptionId = 'sub-old'; Subscription = 'OLD' TenantId = 'tenant-old' KeyVaultName = 'kv'; ClientId = 'cid'; CertificateName = 'cert' } } | Out-Null try { Set-MsecContext -Name 'old' -Confirm:$false -KeepWorkload | Out-Null } catch { $_.Exception.Message } } $msg | Should -Not -BeLike '*Connect-AzAccount -Environment*' } It 'switches this shell only, not every shell opened afterwards' { # Az's context autosave writes the active context to the shared profile, so without a # scope the switch follows you into every new terminal. The app session and the write # session already die with the shell; this was the one that did not. InModuleScope Msec { Mock Get-AzContext -ParameterFilter { $ListAvailable } -MockWith { @() } Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Environment = [pscustomobject]@{ Name = 'AzureCloud' } } } Mock Set-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Write-MsecContextStore @{ dev = [pscustomobject]@{ Name = 'dev'; SubscriptionId = 'sub-dev'; Subscription = 'DEV' TenantId = 'tenant-dev'; Environment = 'AzureCloud' KeyVaultName = 'kv'; ClientId = 'cid'; CertificateName = 'cert' } } | Out-Null Set-MsecContext -Name 'dev' -Confirm:$false -KeepWorkload | Out-Null Should -Invoke Set-AzContext -Times 1 -Exactly -ParameterFilter { $Scope -eq 'Process' } Set-MsecContext -Name 'dev' -Confirm:$false -KeepWorkload -Scope CurrentUser | Out-Null Should -Invoke Set-AzContext -Times 1 -Exactly -ParameterFilter { $Scope -eq 'CurrentUser' } } } It 'keeps the switch when the app session fails to reconnect' { # The context change is what was asked for; losing a session is reported, not undone. $w = InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Tenant = [pscustomobject]@{ Id = 'tenant-1' } } } Mock Set-AzContext -MockWith { } Mock Get-MsecTenantProfile -MockWith { $null } Mock Connect-Msec -MockWith { throw 'vault unreachable' } Mock Get-Module -MockWith { $null } $script:MsecSession = @{ TenantId = 'tenant-1'; ClientId = 'cid'; KeyVaultName = 'kv' } $script:MsecAdminSession = $null Save-MsecContext -Name 'ctx-fail' -Force | Out-Null Set-MsecContext -Name 'ctx-fail' -Confirm:$false -WarningVariable wv -WarningAction SilentlyContinue | Out-Null "$wv" } $w | Should -Match 'did not reconnect' $w | Should -Match 'vault unreachable' } It 'does not prompt when the write session is already on the target tenant' { # THE BUG THIS PREVENTS. With Connect-MsecAdmin scoped to the process there is no # cached refresh token, so calling it again means a browser prompt. Re-selecting a # context you are already on must not open a browser. InModuleScope Msec { Mock Get-AzContext -ParameterFilter { $ListAvailable } -MockWith { @() } Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Environment = [pscustomobject]@{ Name = 'AzureCloud' } } } Mock Set-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Mock Connect-MsecAdmin -MockWith { } Mock Disconnect-MgGraph -MockWith { } $script:MsecAdminSession = [pscustomobject]@{ Account = 'admin@contoso.com'; TenantId = 'tenant-dev' } Write-MsecContextStore @{ dev = [pscustomobject]@{ Name = 'dev'; SubscriptionId = 'sub-dev'; Subscription = 'DEV' TenantId = 'tenant-dev'; Environment = 'AzureCloud'; AdminAccount = 'admin@contoso.com' KeyVaultName = 'kv'; ClientId = 'cid'; CertificateName = 'cert' } } | Out-Null Set-MsecContext -Name 'dev' -Confirm:$false -KeepWorkload | Out-Null Should -Invoke Connect-MsecAdmin -Times 0 Should -Invoke Disconnect-MgGraph -Times 0 $script:MsecAdminSession | Should -Not -BeNullOrEmpty } } It 'disconnects a write session belonging to a tenant you just left' { # Leaving it in place is the dangerous case - it would go on accepting writes aimed at # the old tenant. Carrying it over is not possible without a browser, because the Graph # SDK has no context store and no way to name a cached identity, so it is dropped and # -Admin signs back in when a write is actually intended. $w = InModuleScope Msec { Mock Get-AzContext -ParameterFilter { $ListAvailable } -MockWith { @() } Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Environment = [pscustomobject]@{ Name = 'AzureCloud' } } } Mock Set-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Mock Connect-MsecAdmin -MockWith { } Mock Disconnect-MgGraph -MockWith { } $script:MsecAdminSession = [pscustomobject]@{ Account = 'admin@contoso.com'; TenantId = 'tenant-prod' } Write-MsecContextStore @{ dev = [pscustomobject]@{ Name = 'dev'; SubscriptionId = 'sub-dev'; Subscription = 'DEV' TenantId = 'tenant-dev'; Environment = 'AzureCloud'; AdminAccount = 'admin@contoso.com' KeyVaultName = 'kv'; ClientId = 'cid'; CertificateName = 'cert' } } | Out-Null Set-MsecContext -Name 'dev' -Confirm:$false -KeepWorkload -WarningVariable wv -WarningAction SilentlyContinue | Out-Null Should -Invoke Connect-MsecAdmin -Times 0 Should -Invoke Disconnect-MgGraph -Times 1 -Exactly [pscustomobject]@{ Session = $script:MsecAdminSession; Warning = ($wv -join ' ') } } $w.Session | Should -BeNullOrEmpty $w.Warning | Should -BeLike '*-Admin*' } It 'signs in as the account the context records when -Admin is passed' { # -Admin IS the request for a browser. The account is passed through so a sign-in # landing on whichever identity the browser remembers is refused, not written with. InModuleScope Msec { Mock Get-AzContext -ParameterFilter { $ListAvailable } -MockWith { @() } Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Environment = [pscustomobject]@{ Name = 'AzureCloud' } } } Mock Set-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Mock Connect-MsecAdmin -MockWith { } Mock Disconnect-MgGraph -MockWith { } $script:MsecAdminSession = [pscustomobject]@{ Account = 'old@contoso.com'; TenantId = 'tenant-prod' } Write-MsecContextStore @{ dev = [pscustomobject]@{ Name = 'dev'; SubscriptionId = 'sub-dev'; Subscription = 'DEV' TenantId = 'tenant-dev'; Environment = 'AzureCloud'; AdminAccount = 'admin@contoso.com' KeyVaultName = 'kv'; ClientId = 'cid'; CertificateName = 'cert' } } | Out-Null Set-MsecContext -Name 'dev' -Confirm:$false -KeepWorkload -Admin | Out-Null Should -Invoke Connect-MsecAdmin -Times 1 -Exactly -ParameterFilter { $TenantId -eq 'tenant-dev' -and $Account -eq 'admin@contoso.com' } Should -Invoke Disconnect-MgGraph -Times 0 } } It 'drops a write session even when the context records no admin account' { # A context with no admin account cannot say who to sign in as - which is all the more # reason not to leave the old session pointed at the tenant you just left. $w = InModuleScope Msec { Mock Get-AzContext -ParameterFilter { $ListAvailable } -MockWith { @() } Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Environment = [pscustomobject]@{ Name = 'AzureCloud' } } } Mock Set-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Mock Connect-MsecAdmin -MockWith { } $script:MsecAdminSession = [pscustomobject]@{ Account = 'admin@contoso.com'; TenantId = 'tenant-prod' } Write-MsecContextStore @{ bare = [pscustomobject]@{ Name = 'bare'; SubscriptionId = 'sub-dev'; Subscription = 'DEV' TenantId = 'tenant-dev'; Environment = 'AzureCloud' KeyVaultName = 'kv'; ClientId = 'cid'; CertificateName = 'cert' } } | Out-Null Set-MsecContext -Name 'bare' -Confirm:$false -KeepWorkload -WarningVariable wv -WarningAction SilentlyContinue | Out-Null Should -Invoke Connect-MsecAdmin -Times 0 ($wv -join ' ') } $w | Should -BeLike '*disconnected*' } It 'does not start a write session that was not already there' { # Signing in is interactive, and a prompt in the middle of a context switch is a surprise. $called = InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Tenant = [pscustomobject]@{ Id = 'tenant-1' } } } Mock Set-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Mock Get-MsecTenantProfile -MockWith { $null } Mock Get-Module -MockWith { $null } $script:AdminCalls = 0 Mock Connect-MsecAdmin -MockWith { $script:AdminCalls++ } $script:MsecSession = @{ TenantId = 'tenant-1'; ClientId = 'cid'; KeyVaultName = 'kv' } $script:MsecAdminSession = $null Save-MsecContext -Name 'ctx-noauto' -AdminAccount 'admin@contoso.com' -Force | Out-Null Set-MsecContext -Name 'ctx-noauto' -Confirm:$false | Out-Null $script:AdminCalls } $called | Should -Be 0 } It 'disconnects workload sessions it cannot re-point' { # Left alone they keep answering for the tenant you just left. $dropped = InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' } Tenant = [pscustomobject]@{ Id = 'tenant-1' } } } Mock Set-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Mock Get-MsecTenantProfile -MockWith { $null } Mock Get-Module -MockWith { [pscustomobject]@{ Name = 'ExchangeOnlineManagement' } } ` -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } Mock Get-Module -MockWith { $null } $script:Dropped = $false Mock Get-Command -MockWith { [pscustomobject]@{ Name = 'Disconnect-ExchangeOnline' } } ` -ParameterFilter { $Name -eq 'Disconnect-ExchangeOnline' } Mock Disconnect-ExchangeOnline -MockWith { $script:Dropped = $true } $script:MsecSession = @{ TenantId = 'tenant-1'; ClientId = 'cid'; KeyVaultName = 'kv' } $script:MsecAdminSession = $null Save-MsecContext -Name 'ctx-wl' -Force | Out-Null Set-MsecContext -Name 'ctx-wl' -Confirm:$false | Out-Null $script:Dropped } $dropped | Should -BeTrue } } |