tests/UnipharSecurityAuthUtilities.Tests.ps1

<#
.SYNOPSIS
    Pester tests for the security-critical utility functions in UnipharSecurityAuth.
 
.DESCRIPTION
    Covers the pure/utility helpers used across the security runbooks:
    New-RandomPassword, Protect-LdapFilterValue, Invoke-WithRetry (retry classification and
    Retry-After handling via mocked Start-Sleep), ConvertTo-SendGridAttachment (MIME mapping),
    ConvertTo-EmailRecipientArray (Azure Automation recipient normalisation) and
    Get-Fido2AuthenticatorModel (AAGUID lookup table).
 
.NOTES
    Author: Security Team
    Requires: Pester v5
#>


BeforeAll {
    $modulePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'UnipharSecurityAuth.psm1'
    Import-Module $modulePath -Force

    # Avoid real backoff delays and warning-stream noise during retry tests.
    Mock -ModuleName UnipharSecurityAuth Start-Sleep { }
    Mock -ModuleName UnipharSecurityAuth Write-Warning { }

    # Builds the real exception type Invoke-RestMethod throws, so Invoke-WithRetry can
    # classify it by HTTP status code and read the Retry-After header.
    function New-FakeHttpException {
        param(
            [string]$Message,
            [int]$StatusCode,
            [timespan]$RetryAfterDelta
        )
        $httpResponse = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]$StatusCode)
        if ($PSBoundParameters.ContainsKey('RetryAfterDelta')) {
            $httpResponse.Headers.RetryAfter = [System.Net.Http.Headers.RetryConditionHeaderValue]::new($RetryAfterDelta)
        }
        return [Microsoft.PowerShell.Commands.HttpResponseException]::new($Message, $httpResponse)
    }
}

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

Describe 'New-RandomPassword' -Tags 'Unit' {
    It 'Returns the default length of 90 characters' {
        (New-RandomPassword).Length | Should -Be 90
    }

    It 'Honours a custom length of <Length>' -ForEach @(
        @{ Length = 16 }
        @{ Length = 32 }
        @{ Length = 128 }
    ) {
        (New-RandomPassword -Length $Length).Length | Should -Be $Length
    }

    It 'Contains at least one uppercase, lowercase, digit and special character' {
        $password = New-RandomPassword -Length 32
        $password | Should -MatchExactly '[A-Z]'
        $password | Should -MatchExactly '[a-z]'
        $password | Should -MatchExactly '[0-9]'
        ($password -replace '[a-zA-Z0-9]', '').Length | Should -BeGreaterThan 0
    }

    It 'Generates a different password on each call' {
        $first = New-RandomPassword
        $second = New-RandomPassword
        $first | Should -Not -Be $second
    }
}

Describe 'Protect-LdapFilterValue' -Tags 'Unit' {
    It 'Escapes "<Value>" to "<Expected>"' -ForEach @(
        @{ Value = 'user(test)'; Expected = 'user\28test\29' }
        @{ Value = 'a*b'; Expected = 'a\2ab' }
        @{ Value = 'domain\user'; Expected = 'domain\5cuser' }
        @{ Value = '(*)'; Expected = '\28\2a\29' }
        @{ Value = 'plainvalue'; Expected = 'plainvalue' }
    ) {
        Protect-LdapFilterValue -Value $Value | Should -BeExactly $Expected
    }

    It 'Escapes the backslash before other characters' {
        # A literal backslash must not double-escape the escape sequences it introduces.
        Protect-LdapFilterValue -Value '\*' | Should -BeExactly '\5c\2a'
    }

    It 'Returns the input unchanged for empty or null values' -ForEach @(
        @{ Value = '' }
        @{ Value = $null }
    ) {
        Protect-LdapFilterValue -Value $Value | Should -BeNullOrEmpty
    }
}

Describe 'Invoke-WithRetry' -Tags 'Unit' {
    It 'Returns the script block result on success' {
        Invoke-WithRetry -Script { 'ok' } | Should -Be 'ok'
    }

    It 'Invokes the script block exactly once on success' {
        $counter = [ref]0
        Invoke-WithRetry -Script { $counter.Value++; 'done' } | Out-Null
        $counter.Value | Should -Be 1
    }

    It 'Retries on a 429 status code then succeeds' {
        $counter = [ref]0
        $result = Invoke-WithRetry -Script {
            $counter.Value++
            if ($counter.Value -lt 3) { throw (New-FakeHttpException -Message 'throttled' -StatusCode 429) }
            'recovered'
        } -MaxAttempts 5 -BaseDelaySeconds 1
        $result | Should -Be 'recovered'
        $counter.Value | Should -Be 3
    }

    It 'Retries transient errors matched by message when no status code is present' {
        $counter = [ref]0
        $result = Invoke-WithRetry -Script {
            $counter.Value++
            if ($counter.Value -lt 2) { throw 'rate limit exceeded' }
            'ok'
        } -MaxAttempts 3
        $result | Should -Be 'ok'
        $counter.Value | Should -Be 2
    }

    It 'Does not retry a non-transient error' {
        $counter = [ref]0
        { Invoke-WithRetry -Script { $counter.Value++; throw 'hard failure' } -MaxAttempts 5 } |
            Should -Throw
        $counter.Value | Should -Be 1
    }

    It 'Throws after exhausting MaxAttempts on persistent 503 errors' {
        $counter = [ref]0
        { Invoke-WithRetry -Script {
                $counter.Value++
                throw (New-FakeHttpException -Message 'unavailable' -StatusCode 503)
            } -MaxAttempts 3 } | Should -Throw
        $counter.Value | Should -Be 3
    }

    It 'Honours the Retry-After Delta header for the backoff delay' {
        $counter = [ref]0
        Invoke-WithRetry -Script {
            $counter.Value++
            if ($counter.Value -lt 2) {
                throw (New-FakeHttpException -Message 'slow down' -StatusCode 429 -RetryAfterDelta ([timespan]::FromSeconds(7)))
            }
            'ok'
        } -MaxAttempts 3 | Out-Null

        # Delay is Retry-After (7s) plus 0..1s jitter.
        Should -Invoke -ModuleName UnipharSecurityAuth Start-Sleep -Times 1 -ParameterFilter {
            $Seconds -ge 7 -and $Seconds -lt 8
        }
    }
}

Describe 'ConvertTo-SendGridAttachment' -Tags 'Unit' {
    BeforeAll {
        $csvPath = Join-Path $TestDrive 'report.csv'
        Set-Content -Path $csvPath -Value 'a,b,c' -NoNewline
    }

    It 'Base64-encodes the file content' {
        $attachment = ConvertTo-SendGridAttachment -FilePath $csvPath
        $decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($attachment.Content))
        $decoded | Should -BeExactly 'a,b,c'
    }

    It 'Returns the filename without the directory path' {
        (ConvertTo-SendGridAttachment -FilePath $csvPath).Filename | Should -BeExactly 'report.csv'
    }

    It 'Maps the "<Extension>" extension to "<Expected>"' -ForEach @(
        @{ Extension = '.csv'; Expected = 'text/csv' }
        @{ Extension = '.txt'; Expected = 'text/plain' }
        @{ Extension = '.log'; Expected = 'text/plain' }
        @{ Extension = '.html'; Expected = 'text/html' }
        @{ Extension = '.json'; Expected = 'application/json' }
        @{ Extension = '.pdf'; Expected = 'application/pdf' }
        @{ Extension = '.xlsx'; Expected = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
        @{ Extension = '.bin'; Expected = 'application/octet-stream' }
    ) {
        $path = Join-Path $TestDrive "sample$Extension"
        Set-Content -Path $path -Value 'data' -NoNewline
        (ConvertTo-SendGridAttachment -FilePath $path).Type | Should -BeExactly $Expected
    }

    It 'Throws when the file does not exist' {
        { ConvertTo-SendGridAttachment -FilePath (Join-Path $TestDrive 'missing.csv') } | Should -Throw
    }
}

Describe 'ConvertTo-EmailRecipientArray' -Tags 'Unit' {
    It 'Splits a comma-separated string' {
        $result = ConvertTo-EmailRecipientArray -Recipients 'a@uniphar.ie, b@uniphar.ie'
        $result | Should -Be @('a@uniphar.ie', 'b@uniphar.ie')
    }

    It 'Splits a semicolon-separated string' {
        $result = ConvertTo-EmailRecipientArray -Recipients 'a@uniphar.ie; b@uniphar.ie'
        $result | Should -Be @('a@uniphar.ie', 'b@uniphar.ie')
    }

    It 'Splits a comma-packed single array element (Azure Automation quirk)' {
        $result = ConvertTo-EmailRecipientArray -Recipients @('a@uniphar.ie,b@uniphar.ie')
        $result | Should -Be @('a@uniphar.ie', 'b@uniphar.ie')
    }

    It 'Trims whitespace and removes empty entries' {
        $result = ConvertTo-EmailRecipientArray -Recipients ' a@uniphar.ie ,, b@uniphar.ie '
        $result | Should -Be @('a@uniphar.ie', 'b@uniphar.ie')
    }

    It 'Returns unique sorted addresses' {
        $result = ConvertTo-EmailRecipientArray -Recipients 'b@uniphar.ie, a@uniphar.ie, b@uniphar.ie'
        $result | Should -Be @('a@uniphar.ie', 'b@uniphar.ie')
    }

    It 'Returns an empty array for null input' {
        $result = ConvertTo-EmailRecipientArray -Recipients $null
        @($result).Count | Should -Be 0
    }
}

Describe 'Get-Fido2AuthenticatorModel' -Tags 'Unit' {
    BeforeAll {
        $table = Get-Fido2AuthenticatorModel
    }

    It 'Returns a hashtable lookup' {
        $table | Should -BeOfType [hashtable]
    }

    It 'Resolves a known Android AAGUID with its platform' {
        $entry = $table['ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4']
        $entry.Label | Should -Be 'Google Password Manager'
        $entry.Category | Should -Be 'Mobile'
        $entry.Platform | Should -Be 'Android'
    }

    It 'Resolves a known hardware key AAGUID' {
        $entry = $table['2fc0579f-8113-47ea-b116-bb5a8db9202a']
        $entry.Label | Should -Be 'YubiKey 5 Series'
        $entry.Category | Should -Be 'Hardware'
    }

    It 'Returns null for an unknown AAGUID so callers fall back to Unknown' {
        $table['00000000-0000-0000-0000-000000000000'] | Should -BeNullOrEmpty
    }
}