tests/Get-MsecKeyVaultCertificate.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecKeyVaultCertificate.
#
# The behaviour that matters most is the one a live run proved out immediately: on a real
# tenant, 185 of 258 vaults could be LISTED but not READ INTO. Listing vaults is control-plane
# (Reader); listing the certificates inside one is data-plane, granted separately. Having the
# first without the second is the normal state for an auditor's account.
#
# If an unreadable vault contributed no rows, that inventory would have read as "18
# certificates" with no hint that 71% of the estate was never examined.

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

AfterAll {
    Remove-Module Msec -Force -ErrorAction SilentlyContinue
}

Describe 'Get-MsecKeyVaultCertificate' {

    BeforeEach {
        InModuleScope Msec {
            Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Id = 'sub-1' } } }
        }
    }

    It 'tells a vault it cannot read from an empty one' {
        $rows = InModuleScope Msec {
            Mock Get-AzKeyVault -MockWith {
                [pscustomobject]@{ VaultName = 'kv-ok';      ResourceGroupName = 'rg'; Location = 'westeurope'; VaultUri = 'https://kv-ok.vault.azure.net/' }
                [pscustomobject]@{ VaultName = 'kv-empty';   ResourceGroupName = 'rg'; Location = 'westeurope'; VaultUri = 'https://kv-empty.vault.azure.net/' }
                [pscustomobject]@{ VaultName = 'kv-denied';  ResourceGroupName = 'rg'; Location = 'westeurope'; VaultUri = 'https://kv-denied.vault.azure.net/' }
            }
            Mock Get-AzKeyVaultCertificate -ParameterFilter { $VaultName -eq 'kv-denied' } -MockWith {
                throw 'Operation returned an invalid status code: Forbidden'
            }
            Mock Get-AzKeyVaultCertificate -ParameterFilter { $VaultName -eq 'kv-empty' } -MockWith { @() }
            Mock Get-AzKeyVaultCertificate -ParameterFilter { $VaultName -eq 'kv-ok' -and -not $Name } -MockWith {
                [pscustomobject]@{ Name = 'wildcard' }
            }
            Mock Get-AzKeyVaultCertificate -ParameterFilter { $VaultName -eq 'kv-ok' -and $Name -eq 'wildcard' } -MockWith {
                [pscustomobject]@{
                    Name = 'wildcard'; Enabled = $true; Thumbprint = 'AABB'
                    NotBefore = [datetime]::new(2026, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)
                    Expires   = [datetime]::UtcNow.AddDays(40)
                    SecretId  = 'https://kv-ok.vault.azure.net/secrets/wildcard'
                    Id        = 'https://kv-ok.vault.azure.net/certificates/wildcard'
                    Certificate = [pscustomobject]@{ Subject = 'CN=*.contoso.com'; Issuer = 'CN=DigiCert' }
                }
            }
            Get-MsecKeyVaultCertificate -WarningAction SilentlyContinue
        }

        @($rows).Count | Should -Be 3

        # Three different answers, and only one of them is good news.
        ($rows | Where-Object VaultName -eq 'kv-ok').Status     | Should -Be 'Present'
        ($rows | Where-Object VaultName -eq 'kv-empty').Status  | Should -Be 'Empty'
        ($rows | Where-Object VaultName -eq 'kv-denied').Status | Should -Be 'Unreadable'

        # The denied vault must not look like it holds nothing.
        ($rows | Where-Object VaultName -eq 'kv-denied').Name | Should -BeNullOrEmpty
        ($rows | Where-Object VaultName -eq 'kv-denied').DaysUntilExpiry | Should -BeNullOrEmpty

        $cert = $rows | Where-Object Status -eq 'Present'
        $cert.Subject | Should -Be 'CN=*.contoso.com'
        $cert.Issuer  | Should -Be 'CN=DigiCert'
        $cert.SecretId | Should -Be 'https://kv-ok.vault.azure.net/secrets/wildcard'
    }

    It 'names the data-plane grant when a vault is forbidden' {
        $warnings = @()
        InModuleScope Msec {
            Mock Get-AzKeyVault -MockWith {
                [pscustomobject]@{ VaultName = 'kv-denied'; ResourceGroupName = 'rg'; Location = 'we'; VaultUri = 'u' }
            }
            Mock Get-AzKeyVaultCertificate -MockWith { throw 'Forbidden' }
            Get-MsecKeyVaultCertificate
        } -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null

        # Reader on the vault is not enough, and the message has to say so or the reader will
        # go looking at the wrong permission.
        ($warnings -join ' ') | Should -Match 'Key Vault Reader|access policy'
        ($warnings -join ' ') | Should -Match 'Unreadable rather than empty'
    }

    It 'matches -Tag case-insensitively, on key and on value' {
        $rows = InModuleScope Msec {
            Mock Get-AzKeyVault -MockWith {
                # Azure treats tags as case-insensitive; the Az objects expose an ordinary
                # hashtable that is not, so this vault would be missed by an exact match.
                [pscustomobject]@{ VaultName = 'kv-dns'; ResourceGroupName = 'rg'; Location = 'we'; VaultUri = 'u'
                                   Tags = @{ 'product' = 'dns' } }
                [pscustomobject]@{ VaultName = 'kv-other'; ResourceGroupName = 'rg'; Location = 'we'; VaultUri = 'u'
                                   Tags = @{ 'Product' = 'Billing' } }
            }
            Mock Get-AzKeyVaultCertificate -MockWith { @() }
            Get-MsecKeyVaultCertificate -Tag @{ Product = 'DNS' } -WarningAction SilentlyContinue
        }

        @($rows).Count  | Should -Be 1
        $rows.VaultName | Should -Be 'kv-dns'
    }

    It 'keeps expired certificates inside any -ExpiringWithinDays window' {
        $rows = InModuleScope Msec {
            Mock Get-AzKeyVault -MockWith {
                [pscustomobject]@{ VaultName = 'kv'; ResourceGroupName = 'rg'; Location = 'we'; VaultUri = 'u' }
            }
            Mock Get-AzKeyVaultCertificate -ParameterFilter { -not $Name } -MockWith {
                [pscustomobject]@{ Name = 'long-dead' }
                [pscustomobject]@{ Name = 'due-soon' }
                [pscustomobject]@{ Name = 'healthy' }
            }
            Mock Get-AzKeyVaultCertificate -ParameterFilter { $Name } -MockWith {
                $days = switch ($Name) { 'long-dead' { -400 } 'due-soon' { 20 } 'healthy' { 300 } }
                [pscustomobject]@{
                    Name = $Name; Enabled = $true; Thumbprint = 'X'
                    NotBefore = [datetime]::UtcNow.AddDays(-800)
                    Expires = [datetime]::UtcNow.AddDays($days)
                    SecretId = 's'; Id = 'i'
                    Certificate = [pscustomobject]@{ Subject = 'CN=x'; Issuer = 'CN=y' }
                }
            }
            Get-MsecKeyVaultCertificate -ExpiringWithinDays 30 -WarningAction SilentlyContinue
        }

        # A certificate that lapsed 400 days ago is not less urgent than one lapsing next week.
        @($rows | ForEach-Object { $_.Name } | Sort-Object) | Should -Be @('due-soon', 'long-dead')
        ($rows | Where-Object Name -eq 'long-dead').IsExpired | Should -BeTrue
    }

    It 'excludes disabled certificates unless asked' {
        $script:Make = {
            InModuleScope Msec -Parameters @{ IncludeDisabled = $args[0] } {
                param($IncludeDisabled)
                Mock Get-AzKeyVault -MockWith {
                    [pscustomobject]@{ VaultName = 'kv'; ResourceGroupName = 'rg'; Location = 'we'; VaultUri = 'u' }
                }
                Mock Get-AzKeyVaultCertificate -ParameterFilter { -not $Name } -MockWith {
                    [pscustomobject]@{ Name = 'live' }; [pscustomobject]@{ Name = 'retired' }
                }
                Mock Get-AzKeyVaultCertificate -ParameterFilter { $Name } -MockWith {
                    [pscustomobject]@{
                        Name = $Name; Enabled = ($Name -eq 'live'); Thumbprint = 'X'
                        NotBefore = [datetime]::UtcNow.AddDays(-10); Expires = [datetime]::UtcNow.AddDays(10)
                        SecretId = 's'; Id = 'i'; Certificate = [pscustomobject]@{ Subject = 'CN=x'; Issuer = 'CN=y' }
                    }
                }
                if ($IncludeDisabled) { Get-MsecKeyVaultCertificate -IncludeDisabled -WarningAction SilentlyContinue }
                else { Get-MsecKeyVaultCertificate -WarningAction SilentlyContinue }
            }
        }

        # A disabled certificate is presented to nothing, so its expiry is not an outage.
        @(& $script:Make $false | ForEach-Object { $_.Name }) | Should -Be @('live')
        @(& $script:Make $true  | ForEach-Object { $_.Name } | Sort-Object) | Should -Be @('live', 'retired')
    }

    It 'throws a clear error without an Az context' {
        InModuleScope Msec {
            Mock Get-AzContext -MockWith { $null }
            { Get-MsecKeyVaultCertificate } | Should -Throw '*Connect-AzAccount*'
        }
    }
}