Tests/MsecCompletionCache.Tests.ps1

#Requires -Module Pester
#
# Tests for the module's completion cache - the only on-disk state it keeps - and for the
# -Subscription completer that every Azure-facing command shares.
#
# The cache exists so completers never call Azure: one that queries ARM blocks the prompt on
# every Tab, and when ARM is unhealthy it hangs rather than failing fast.

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

    $script:PrevCacheEnv = $env:MSEC_CACHE_DIR
    $env:MSEC_CACHE_DIR  = Join-Path ([System.IO.Path]::GetTempPath()) "msec-test-cache-$([guid]::NewGuid())"
    New-Item -ItemType Directory -Path $env:MSEC_CACHE_DIR -Force | Out-Null
    $script:CacheDir = $env:MSEC_CACHE_DIR
}

AfterAll {
    if ($script:CacheDir -and (Test-Path -LiteralPath $script:CacheDir)) {
        Remove-Item -LiteralPath $script:CacheDir -Recurse -Force
    }
    $env:MSEC_CACHE_DIR = $script:PrevCacheEnv
    Remove-Module Msec -Force -ErrorAction SilentlyContinue
}

Describe 'Msec completion cache' {
    It 'honours MSEC_CACHE_DIR, scopes by tenant, and defaults to per-user state' {
        # The override is what lets the whole suite run without touching the developer's real
        # cache. The tenant folder is what lets two tenants stay warm across a context flip
        # instead of overwriting each other. The default must not be the module folder -
        # routinely read-only, and shared.
        $paths = InModuleScope Msec {
            Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'tenant-A' } } }
            $a = Get-MsecCachePath -Name 'subscriptions'
            Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'tenant-B' } } }
            $b = Get-MsecCachePath -Name 'subscriptions'
            $saved = $env:MSEC_CACHE_DIR
            $env:MSEC_CACHE_DIR = $null
            try { $d = Get-MsecCachePath -Name 'subscriptions' } finally { $env:MSEC_CACHE_DIR = $saved }
            [pscustomobject]@{ A = $a; B = $b; Default = $d }
        }

        $paths.A | Should -Be (Join-Path (Join-Path $script:CacheDir 'tenant-A') 'subscriptions.json')
        $paths.B | Should -Be (Join-Path (Join-Path $script:CacheDir 'tenant-B') 'subscriptions.json')
        $paths.Default | Should -Not -BeLike "$((Get-Module Msec).ModuleBase)*"
    }

    It 'round-trips items, and degrades to empty rather than throwing' {
        # Read-MsecCache is called from completers, so a missing or hand-mangled file must
        # degrade to silence - a completer that throws breaks the prompt itself.
        $result = InModuleScope Msec {
            Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'tenant-1' } } }
            Save-MsecCache -Name 'subscriptions' -Item @(
                [pscustomobject]@{ Id = 'sub-1'; Name = 'Contoso-Prod'; TenantId = 't1' }
            )
            $corrupt = Get-MsecCachePath -Name 'corrupt'
            New-Item -ItemType Directory -Path (Split-Path -Parent $corrupt) -Force | Out-Null
            Set-Content -LiteralPath $corrupt -Value '{ not json'
            $saved = @(Read-MsecCache -Name 'subscriptions')
            # Another tenant reads its own folder, so it sees nothing of this one - and this
            # one's cache survives the flip rather than being discarded and overwritten.
            Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'a-different-tenant' } } }
            $otherTenant = @(Read-MsecCache -Name 'subscriptions')
            Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'tenant-1' } } }
            [pscustomobject]@{
                Saved       = $saved
                Missing     = @(Read-MsecCache -Name 'no-such-cache')
                Corrupt     = @(Read-MsecCache -Name 'corrupt')
                OtherTenant = $otherTenant
                StillThere  = @(Read-MsecCache -Name 'subscriptions')
            }
        }

        $result.Saved.Name        | Should -Be 'Contoso-Prod'
        $result.Missing.Count     | Should -Be 0
        $result.Corrupt.Count     | Should -Be 0
        $result.OtherTenant.Count | Should -Be 0
        $result.StillThere.Name   | Should -Be 'Contoso-Prod'
    }

}

Describe '-Subscription completion' {
    BeforeAll {
        # Both ends have to agree on the tenant, because the cache is partitioned by it: this
        # BeforeAll writes the cache, and the completer under test reads it back - but the
        # completer runs OUTSIDE InModuleScope, via TabExpansion2. Mocking only the write side
        # would send it to a folder the read side never looks in.
        #
        # -ModuleName msec is what makes that work: it intercepts the module's own calls to
        # Get-AzContext wherever they come from, including the completer's
        # `& $module { Read-MsecCache }`. This used to rely on the developer's real context
        # being present on both sides, which is true locally and false on any CI runner.
        Mock -ModuleName msec Get-AzContext -MockWith {
            [pscustomobject]@{ Tenant = @{ Id = 'completion-tenant' } }
        }
        InModuleScope Msec {
            Save-MsecCache -Name 'subscriptions' -Item @(
                [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Contoso-Prod';    TenantId = 't1' }
                [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Contoso-Prod-US'; TenantId = 't1' }
                [pscustomobject]@{ Id = '33333333-3333-3333-3333-333333333333'; Name = 'Fabrikam-Dev';    TenantId = 't1' }
                [pscustomobject]@{ Id = '44444444-4444-4444-4444-444444444444'; Name = 'Shared-Name';    TenantId = 't1' }
                [pscustomobject]@{ Id = '55555555-5555-5555-5555-555555555555'; Name = 'Shared-Name';    TenantId = 't2' }
            )
        }
    }

    It 'completes -Subscription to the name, or to the id when that name is ambiguous' {
        $line = 'Search-MsecAzureResourceGraph -ResourceType VM -Subscription Contoso'
        $byName = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches
        $byName.CompletionText | Should -Contain 'Contoso-Prod'
        $byName.CompletionText | Should -Contain 'Contoso-Prod-US'
        $byName.CompletionText | Should -Not -Contain 'Fabrikam-Dev'
        # The id is still shown, so you know which subscription you are picking.
        ($byName | Where-Object CompletionText -eq 'Contoso-Prod').ListItemText |
            Should -Match '11111111-1111-1111-1111-111111111111'

        # A name two subscriptions share is completed as the ID: the name would bind and then
        # fail as ambiguous, so offering it would hand over a value known not to work.
        $line = 'Search-MsecAzureResourceGraph -ResourceType VM -Subscription Shared'
        $ambiguous = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText
        $ambiguous | Should -Contain '44444444-4444-4444-4444-444444444444'
        $ambiguous | Should -Contain '55555555-5555-5555-5555-555555555555'
        $ambiguous | Should -Not -Contain 'Shared-Name'
    }

    It 'never makes a network call from the completer' {
        # Structural guard, so that "improving" this into a live Get-AzSubscription fails here
        # first rather than hanging someone's prompt during an ARM outage.
        $psm1  = Get-Content -LiteralPath (Join-Path (Get-Module Msec).ModuleBase 'Msec.psm1') -Raw
        $start = $psm1.IndexOf('$msecSubscriptionCompleter = {')
        $body  = $psm1.Substring($start, $psm1.IndexOf('Register-ArgumentCompleter') - $start)

        foreach ($forbidden in 'Get-AzSubscription', 'Search-AzGraph', 'Invoke-Az') {
            $body | Should -Not -Match ([regex]::Escape($forbidden))
        }
    }

    It 'refreshes the cache on an estate-wide call, but does not enumerate when scoped' {
        # The cmdlet already enumerates every accessible subscription when unscoped, so keeping
        # the cache warm costs nothing - and -CurrentSubscription must not smuggle that
        # enumeration back in.
        $cached = InModuleScope Msec {
            Mock Get-AzContext      -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Id = 'sub-current' }; Tenant = @{ Id = 'tenant-1' } } }
            Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'brand-new-sub'; Name = 'Newly-Added'; TenantId = 't1' }) }
            Mock Search-AzGraph     -MockWith { @() }
            $null = Search-MsecAzureResourceGraph -ResourceType VM
            Read-MsecCache -Name 'subscriptions'
        }
        $cached.Name | Should -Contain 'Newly-Added'

        InModuleScope Msec {
            Mock Get-AzContext      -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Id = 'sub-current' }; Tenant = @{ Id = 'tenant-1' } } }
            Mock Get-AzSubscription -MockWith { throw 'should not enumerate when scoped' }
            Mock Search-AzGraph     -MockWith { @() }
            { Search-MsecAzureResourceGraph -ResourceType VM -CurrentSubscription } | Should -Not -Throw
        }
    }
}