tests/MsecTenantProfile.Tests.ps1
|
#Requires -Module Pester # # Tests for the tenant connection profile - what makes switching Azure context also reconnect # the msec app session. # # The point of the feature is that msec runs on TWO identities: the Az context is you, the # msec session is the app registration. Switching one used to leave the other pointing at the # tenant you just left, so every Graph call kept answering for the wrong tenant and all you # got was a warning. # # What must hold: # * a profile stores CONFIGURATION only - vault, client id, cert name. No secret, because # there isn't one: signing happens inside Key Vault and the private key never leaves it. # * profiles are per tenant and can never overwrite each other # * a missing, unreadable or incomplete profile is silently "no profile", never an error in # the middle of a context switch # * a failed reconnect must not fail the context switch that was actually asked for BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop $script:CacheDir = Join-Path ([System.IO.Path]::GetTempPath()) "msec-profile-$([guid]::NewGuid().Guid)" $env:MSEC_CACHE_DIR = $script:CacheDir } AfterAll { Remove-Item env:MSEC_CACHE_DIR -ErrorAction SilentlyContinue if ($script:CacheDir -and (Test-Path $script:CacheDir)) { Remove-Item $script:CacheDir -Recurse -Force -ErrorAction SilentlyContinue } Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'tenant connection profile' { It 'round-trips, and keeps tenants apart' { InModuleScope Msec { Save-MsecTenantProfile -TenantId 'tenant-a' -KeyVaultName 'kv-a' -ClientId 'client-a' Save-MsecTenantProfile -TenantId 'tenant-b' -KeyVaultName 'kv-b' -ClientId 'client-b' -CertificateName 'other-cert' $a = Get-MsecTenantProfile -TenantId 'tenant-a' $b = Get-MsecTenantProfile -TenantId 'tenant-b' $a.KeyVaultName | Should -Be 'kv-a' $a.ClientId | Should -Be 'client-a' $a.CertificateName | Should -Be 'msec-app' # the default, recorded explicitly # Saving B must not have touched A - the whole reason profiles are per-tenant # folders rather than one shared file. $b.KeyVaultName | Should -Be 'kv-b' $b.CertificateName | Should -Be 'other-cert' $a.KeyVaultName | Should -Be 'kv-a' } } It 'stores no secret' { InModuleScope Msec { Save-MsecTenantProfile -TenantId 'tenant-nokeys' -KeyVaultName 'kv' -ClientId 'client' $raw = Get-Content -LiteralPath (Get-MsecCachePath -Name 'profile' -TenantId 'tenant-nokeys') -Raw # Signing happens inside Key Vault, so there is nothing secret to leak here - but # a future change that started caching key material would be a real problem, and # this is where it would show up. $raw | Should -Not -Match '(?i)privatekey|thumbprint|secret|password|-----BEGIN' } } It 'treats a missing, malformed or incomplete profile as no profile' { InModuleScope Msec { Get-MsecTenantProfile -TenantId 'tenant-never-seen' | Should -BeNullOrEmpty # Hand-edited into nonsense. $path = Get-MsecCachePath -Name 'profile' -TenantId 'tenant-broken' New-Item -Path (Split-Path $path -Parent) -ItemType Directory -Force | Out-Null Set-Content -LiteralPath $path -Value '{ not json at all' Get-MsecTenantProfile -TenantId 'tenant-broken' | Should -BeNullOrEmpty # Valid JSON, but without what Connect-Msec needs - e.g. written by an older # version. Ignored rather than turned into a failure mid-switch. $path = Get-MsecCachePath -Name 'profile' -TenantId 'tenant-partial' New-Item -Path (Split-Path $path -Parent) -ItemType Directory -Force | Out-Null Set-Content -LiteralPath $path -Value '{ "TenantId": "tenant-partial" }' Get-MsecTenantProfile -TenantId 'tenant-partial' | Should -BeNullOrEmpty } } It 'never fails the caller when it cannot be saved' { InModuleScope Msec { Mock Set-Content -MockWith { throw 'read-only volume' } # Losing auto-reconnect must not cost the session that just connected. { Save-MsecTenantProfile -TenantId 'tenant-ro' -KeyVaultName 'kv' -ClientId 'c' } | Should -Not -Throw } } } Describe 'Select-MsecAzureContext reconnect' { BeforeEach { InModuleScope Msec { $script:MsecSession = $null } } It 'reconnects the app session for a tenant with a saved profile' { InModuleScope Msec { Save-MsecTenantProfile -TenantId 'tenant-x' -KeyVaultName 'kv-x' -ClientId 'client-x' $ctx = [pscustomobject]@{ Name = 'Prod'; Tenant = [pscustomobject]@{ Id = 'tenant-x' } Subscription = [pscustomobject]@{ Name = 'Prod'; Id = 'sub-1' } Account = [pscustomobject]@{ Id = 'me@x.com' }; Environment = 'AzureCloud' } Mock Get-AzContext -MockWith { $ctx } Mock Select-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Select-MsecAzureContext -Subscription 'Prod' | Out-Null # Replayed from the profile, against the tenant just switched to, and with -NoSave # because this is replaying a profile rather than creating one. Should -Invoke Connect-Msec -Times 1 -Exactly -ParameterFilter { $KeyVaultName -eq 'kv-x' -and $ClientId -eq 'client-x' -and $TenantId -eq 'tenant-x' -and $NoSave } } } It 'does not reconnect with -NoConnect, or when the session is already that tenant' { InModuleScope Msec { Save-MsecTenantProfile -TenantId 'tenant-y' -KeyVaultName 'kv-y' -ClientId 'client-y' $ctx = [pscustomobject]@{ Name = 'Prod'; Tenant = [pscustomobject]@{ Id = 'tenant-y' } Subscription = [pscustomobject]@{ Name = 'Prod'; Id = 'sub-1' } Account = [pscustomobject]@{ Id = 'me@x.com' }; Environment = 'AzureCloud' } Mock Get-AzContext -MockWith { $ctx } Mock Select-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Select-MsecAzureContext -Subscription 'Prod' -NoConnect | Out-Null Should -Invoke Connect-Msec -Times 0 -Exactly # Already on this tenant: a switch between two subscriptions in one tenant must # not re-authenticate. $script:MsecSession = @{ TenantId = 'tenant-y'; Endpoints = @{ EnvironmentName = 'AzureCloud' } } Select-MsecAzureContext -Subscription 'Prod' | Out-Null Should -Invoke Connect-Msec -Times 0 -Exactly } } It 'still switches the context when the reconnect fails' { $warnings = @() $result = InModuleScope Msec { Save-MsecTenantProfile -TenantId 'tenant-z' -KeyVaultName 'kv-gone' -ClientId 'client-z' $ctx = [pscustomobject]@{ Name = 'Prod'; Tenant = [pscustomobject]@{ Id = 'tenant-z' } Subscription = [pscustomobject]@{ Name = 'Prod'; Id = 'sub-9' } Account = [pscustomobject]@{ Id = 'me@x.com' }; Environment = 'AzureCloud' } Mock Get-AzContext -MockWith { $ctx } Mock Select-AzContext -MockWith { } Mock Connect-Msec -MockWith { throw 'The vault kv-gone was not found.' } Select-MsecAzureContext -Subscription 'Prod' } -WarningVariable warnings -WarningAction SilentlyContinue # The thing actually asked for succeeded. $result.SubscriptionId | Should -Be 'sub-9' $result.TenantId | Should -Be 'tenant-z' ($warnings -join ' ') | Should -Match 'could not reconnect' ($warnings -join ' ') | Should -Match 'kv-gone' } It 'does nothing special for a tenant with no profile' { InModuleScope Msec { $ctx = [pscustomobject]@{ Name = 'Prod'; Tenant = [pscustomobject]@{ Id = 'tenant-unknown' } Subscription = [pscustomobject]@{ Name = 'Prod'; Id = 'sub-2' } Account = [pscustomobject]@{ Id = 'me@x.com' }; Environment = 'AzureCloud' } Mock Get-AzContext -MockWith { $ctx } Mock Select-AzContext -MockWith { } Mock Connect-Msec -MockWith { } Select-MsecAzureContext -Subscription 'Prod' | Out-Null Should -Invoke Connect-Msec -Times 0 -Exactly } } } |