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 } } Describe 'Test-PasswordlessReady' -Tags 'Unit' { Context 'No managed phone — any passkey is sufficient' { It 'Not ready with no passkeys at all' { Test-PasswordlessReady | Should -BeFalse } It 'Ready with a single hardware key' { Test-PasswordlessReady -HardwareKeys 1 | Should -BeTrue } It 'Ready with a Windows platform passkey' { Test-PasswordlessReady -WindowsPasskeys 1 | Should -BeTrue } It 'Ready with an unknown-AAGUID passkey' { Test-PasswordlessReady -UnknownPasskeys 1 | Should -BeTrue } It 'Ready with a mobile passkey even without a managed phone' { Test-PasswordlessReady -IosPasskeys 1 | Should -BeTrue } } Context 'iOS phone — needs 1 iOS passkey per phone' { It 'Not ready: iOS phone with no iOS passkey' { Test-PasswordlessReady -IosPhones 1 | Should -BeFalse } It 'Not ready: iOS phone covered only by a hardware key (key cannot sign in on the phone)' { Test-PasswordlessReady -IosPhones 1 -HardwareKeys 1 | Should -BeFalse } It 'Ready: iOS phone with 1 iOS passkey' { Test-PasswordlessReady -IosPhones 1 -IosPasskeys 1 | Should -BeTrue } It 'Not ready: two iOS phones with only one iOS passkey' { Test-PasswordlessReady -IosPhones 2 -IosPasskeys 1 | Should -BeFalse } } Context 'Android phone — needs 2 Android passkeys per phone' { It 'Not ready: Android phone with 1 Android passkey' { Test-PasswordlessReady -AndroidPhones 1 -AndroidPasskeys 1 | Should -BeFalse } It 'Ready: Android phone with 2 Android passkeys' { Test-PasswordlessReady -AndroidPhones 1 -AndroidPasskeys 2 | Should -BeTrue } It 'Not ready: Android phone covered only by a hardware key' { Test-PasswordlessReady -AndroidPhones 1 -HardwareKeys 5 | Should -BeFalse } } Context 'Both Android and iOS phones — every phone must be covered' { It 'Ready: 1 Android (2 passkeys) + 1 iOS (1 passkey)' { Test-PasswordlessReady -AndroidPhones 1 -IosPhones 1 -AndroidPasskeys 2 -IosPasskeys 1 | Should -BeTrue } It 'Not ready: Android covered but iOS not' { Test-PasswordlessReady -AndroidPhones 1 -IosPhones 1 -AndroidPasskeys 2 -IosPasskeys 0 | Should -BeFalse } } } |