tests/Connect-MsecAzure.Tests.ps1

#Requires -Module Pester
#
# Tests for Connect-MsecAzure.
#
# The traps:
#
# IT DEFAULTS TO -Scope Process WHERE Connect-AzAccount DEFAULTS TO CurrentUser. Az's context
# autosave writes the active context to the shared profile, so a sign-in in one shell becomes
# the default for every shell opened afterwards.
#
# THE macOS BROKER FAILURE NAMES NOTHING. "Interactive requests with mac broker enabled must
# be executed on the main thread" says nothing about EnableLoginByWam, which is the fix.

BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop
    $script:Stubbed = @()
    foreach ($c in 'Connect-AzAccount', 'Get-AzContext', 'Get-AzEnvironment') {
        if (-not (Get-Command $c -ErrorAction SilentlyContinue)) {
            Set-Item "function:global:$c" -Value {
                [CmdletBinding()] param($Subscription, $Tenant, $Environment, $Scope,
                                        [switch]$UseDeviceAuthentication, [switch]$ListAvailable)
            } -Force
            $script:Stubbed += $c
        }
    }
}
AfterAll {
    foreach ($c in $script:Stubbed) { Remove-Item "function:$c" -ErrorAction SilentlyContinue }
    Remove-Module Msec -Force -ErrorAction SilentlyContinue
}

Describe 'Connect-MsecAzure' {

    BeforeEach {
        InModuleScope Msec {
            Mock Get-Module -ParameterFilter { $ListAvailable } -MockWith { [pscustomobject]@{ Name = 'Az.Accounts' } }
            Mock Import-Module -MockWith { }
            Mock Get-AzContext -MockWith {
                [pscustomobject]@{
                    Account      = [pscustomobject]@{ Id = 'me@contoso.com' }
                    Subscription = [pscustomobject]@{ Id = 'sub-1'; Name = 'PROD' }
                    Tenant       = [pscustomobject]@{ Id = 'tenant-1' }
                    Environment  = [pscustomobject]@{ Name = 'AzureCloud' }
                }
            }
        }
    }

    It 'keeps the sign-in in this shell by default' {
        # THE BUG THIS PREVENTS. Connect-AzAccount defaults to CurrentUser, which writes the
        # active context to ~/.Azure/AzureRmContext.json - so signing in here silently
        # re-points every shell opened afterwards.
        $scope = InModuleScope Msec {
            Mock Connect-AzAccount -MockWith { $script:SeenScope = $Scope }
            $null = Connect-MsecAzure
            $script:SeenScope
        }

        $scope | Should -Be 'Process'
    }

    It 'writes the shared profile only when asked' {
        $scope = InModuleScope Msec {
            Mock Connect-AzAccount -MockWith { $script:SeenScope = $Scope }
            $null = Connect-MsecAzure -Scope CurrentUser
            $script:SeenScope
        }

        $scope | Should -Be 'CurrentUser'
    }

    It 'names EnableLoginByWam when the macOS broker refuses the thread' {
        # The Azure error mentions the main thread and the broker but never the setting that
        # turns it off, which is the only thing a reader can act on.
        $msg = InModuleScope Msec {
            Mock Connect-AzAccount -MockWith {
                throw 'InteractiveBrowserCredential authentication failed: Interactive requests with mac broker enabled must be executed on the main thread on macOS.'
            }
            try { Connect-MsecAzure | Out-Null } catch { $_.Exception.Message }
        }

        $msg | Should -BeLike '*EnableLoginByWam*'
        $msg | Should -BeLike '*-UseDeviceCode*'
    }

    It 'does not blame the broker for an unrelated sign-in failure' {
        # Matching too eagerly would send someone to a config setting that is not their problem.
        $msg = InModuleScope Msec {
            Mock Connect-AzAccount -MockWith { throw 'AADSTS50076: Multi-factor authentication required.' }
            try { Connect-MsecAzure | Out-Null } catch { $_.Exception.Message }
        }

        $msg | Should -BeLike '*AADSTS50076*'
        $msg | Should -Not -BeLike '*EnableLoginByWam*'
    }

    It 'passes the cloud through, since a sovereign tenant cannot be reached without it' {
        $seen = InModuleScope Msec {
            Mock Connect-AzAccount -MockWith { $script:SeenEnv = $Environment }
            $null = Connect-MsecAzure -Environment AzureChinaCloud
            $script:SeenEnv
        }

        $seen | Should -Be 'AzureChinaCloud'
    }

    It 'asks for a device code only when told to' {
        $seen = InModuleScope Msec {
            Mock Connect-AzAccount -MockWith { $script:SeenDev = [bool] $UseDeviceAuthentication }
            $null = Connect-MsecAzure
            $a = $script:SeenDev
            $null = Connect-MsecAzure -UseDeviceCode
            [pscustomobject]@{ Default = $a; Asked = $script:SeenDev }
        }

        $seen.Default | Should -BeFalse
        $seen.Asked   | Should -BeTrue
    }

    It 'reports which subscription the shell actually landed on' {
        # The question every Azure-plane command depends on, and the one the sign-in itself
        # does not answer clearly.
        $r = InModuleScope Msec {
            Mock Connect-AzAccount -MockWith { }
            Connect-MsecAzure
        }

        $r.Subscription   | Should -Be 'PROD'
        $r.SubscriptionId | Should -Be 'sub-1'
        $r.TenantId       | Should -Be 'tenant-1'
        $r.Environment    | Should -Be 'AzureCloud'
        $r.Scope          | Should -Be 'Process'
    }

    It 'completes the cloud from Get-AzEnvironment rather than a fixed list' {
        # NOT A ValidateSet ON PURPOSE. Add-AzEnvironment registers clouds Az does not ship
        # with, and an air-gapped or national cloud is exactly where this command is needed
        # most - a fixed list would complete nicely and then refuse a cloud that exists.
        #
        # MOCKED IN MODULE SCOPE. The completer scriptblock is defined in the module file, so
        # it resolves Get-AzEnvironment there - a function declared in the test's own scope
        # would be ignored and the real Az would answer.
        $r = InModuleScope Msec {
            Mock Get-AzEnvironment -MockWith {
                @([pscustomobject]@{ Name = 'AzureCloud';      ResourceManagerUrl = 'https://management.azure.com/' },
                  [pscustomobject]@{ Name = 'AzureChinaCloud'; ResourceManagerUrl = 'https://management.chinacloudapi.cn/' },
                  [pscustomobject]@{ Name = 'AirGapped';       ResourceManagerUrl = 'https://management.example.invalid/' })
            }
            $sb = (Get-Command Connect-MsecAzure).Parameters['Environment'].Attributes |
                      Where-Object { $_ -is [System.Management.Automation.ArgumentCompleterAttribute] } |
                      ForEach-Object { $_.ScriptBlock }
            [pscustomobject]@{
                All    = @(& $sb 'Connect-MsecAzure' 'Environment' ''       $null @{}).CompletionText
                Narrow = @(& $sb 'Connect-MsecAzure' 'Environment' 'AzureC' $null @{}).CompletionText
            }
        }

        $r.All    | Should -Contain 'AirGapped'
        $r.Narrow | Should -Contain 'AzureChinaCloud'
        $r.Narrow | Should -Not -Contain 'AirGapped'
    }

    It 'completes nothing instead of erroring when Az cannot be asked' {
        # Completers run on every Tab. One that throws makes the shell look broken.
        $r = InModuleScope Msec {
            Mock Get-AzEnvironment -MockWith { throw 'Az is not available' }
            $sb = (Get-Command Connect-MsecAzure).Parameters['Environment'].Attributes |
                      Where-Object { $_ -is [System.Management.Automation.ArgumentCompleterAttribute] } |
                      ForEach-Object { $_.ScriptBlock }
            [pscustomobject]@{
                Threw  = $(try { $null = & $sb 'Connect-MsecAzure' 'Environment' '' $null @{}; $false } catch { $true })
                Result = @(& $sb 'Connect-MsecAzure' 'Environment' '' $null @{})
            }
        }

        $r.Threw  | Should -BeFalse
        $r.Result | Should -BeNullOrEmpty
    }

    It 'leaves context population alone unless asked, and passes it through when asked' {
        # NOT LOWERED BY DEFAULT. Those populated contexts are what lets Set-MsecContext cross
        # clouds without a fresh sign-in, so quietly capping them would fix a slow sign-in by
        # breaking context switching.
        # ASSERTED WITH Should -Invoke, not by reading $PSBoundParameters inside the mock
        # body - that is empty there, which reads as "the parameter was never passed".
        InModuleScope Msec {
            Mock Connect-AzAccount -MockWith { }

            $null = Connect-MsecAzure
            Should -Invoke Connect-AzAccount -Times 1 -Exactly -ParameterFilter {
                -not $PSBoundParameters.ContainsKey('MaxContextPopulation')
            }

            $null = Connect-MsecAzure -MaxContextPopulation 1
            Should -Invoke Connect-AzAccount -Times 1 -Exactly -ParameterFilter {
                $MaxContextPopulation -eq 1
            }
        }
    }

    It 'throws rather than reporting success when no context came back' {
        $msg = InModuleScope Msec {
            Mock Connect-AzAccount -MockWith { }
            Mock Get-AzContext -MockWith { $null }
            try { Connect-MsecAzure | Out-Null } catch { $_.Exception.Message }
        }

        $msg | Should -BeLike '*did not complete*'
    }
}