tests/UnipharCanaryResponse.Tests.ps1

<#
.SYNOPSIS
    Pester tests for the Thinkst Canary phishing response helper functions in UnipharSecurityAuth.
 
.DESCRIPTION
    Covers the pure helper functions (ConvertFrom-CanaryIncident, ConvertTo-CanaryCidr,
    Test-IpInCidrRange, Test-IpAllowListed) and the REST/Graph-dependent functions via mocks
    (Confirm-CanaryConnectivity, Get-CanaryIncident, Add-IpToRestrictedNamedLocation,
    Revoke-UserAccess), including dry-run (-WhatIf) gating. Consumed by the
    Invoke-CanaryPhishingResponse runbook.
 
.NOTES
    Author: Security Team
    Requires: Pester v5
#>


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

    # Stub the Microsoft Graph cmdlets (global scope) so Mock -ModuleName can reliably resolve
    # them regardless of Pester version, without the Graph SDK installed.
    function global:Get-MgIdentityConditionalAccessNamedLocation { param($All) }
    function global:Update-MgIdentityConditionalAccessNamedLocation { param($NamedLocationId, $BodyParameter) }
    function global:Get-MgAuditLogSignIn { param($Filter, $All) }
    function global:Revoke-MgUserSignInSession { param($UserId) }
    function global:Invoke-MgGraphRequest { param($Method, $Uri, $Body) }
}

AfterAll {
    Remove-Item -Path 'function:global:Get-MgIdentityConditionalAccessNamedLocation' -ErrorAction SilentlyContinue
    Remove-Item -Path 'function:global:Update-MgIdentityConditionalAccessNamedLocation' -ErrorAction SilentlyContinue
    Remove-Item -Path 'function:global:Get-MgAuditLogSignIn' -ErrorAction SilentlyContinue
    Remove-Item -Path 'function:global:Revoke-MgUserSignInSession' -ErrorAction SilentlyContinue
    Remove-Item -Path 'function:global:Invoke-MgGraphRequest' -ErrorAction SilentlyContinue
    Remove-Module UnipharSecurityAuth -Force -ErrorAction SilentlyContinue
}

Describe 'ConvertTo-CanaryCidr' -Tags 'Unit' {
    It 'Normalizes bare IPv4 "<Value>" to "<Expected>"' -ForEach @(
        @{ Value = '203.0.113.5'; Expected = '203.0.113.5/32' }
        @{ Value = ' 198.51.100.10 '; Expected = '198.51.100.10/32' }
    ) {
        ConvertTo-CanaryCidr -IpAddress $Value | Should -BeExactly $Expected
    }

    It 'Normalizes bare IPv6 to /128' {
        ConvertTo-CanaryCidr -IpAddress '2001:db8::1' | Should -BeExactly '2001:db8::1/128'
    }

    It 'Leaves an existing CIDR unchanged' {
        ConvertTo-CanaryCidr -IpAddress '203.0.113.0/24' | Should -BeExactly '203.0.113.0/24'
    }

    It 'Returns $null for invalid input "<Value>"' -ForEach @(
        @{ Value = 'not-an-ip' }
        @{ Value = ' ' }
        @{ Value = '999.999.999.999' }
    ) {
        ConvertTo-CanaryCidr -IpAddress $Value | Should -BeNullOrEmpty
    }
}

Describe 'Test-IpInCidrRange' -Tags 'Unit' {
    It 'Returns $true when "<Ip>" is inside "<Cidr>"' -ForEach @(
        @{ Ip = '203.0.113.5'; Cidr = '203.0.113.0/24' }
        @{ Ip = '203.0.113.5'; Cidr = '203.0.113.5/32' }
        @{ Ip = '10.1.2.3'; Cidr = '10.0.0.0/8' }
        @{ Ip = '192.168.1.130'; Cidr = '192.168.1.128/25' }
        @{ Ip = '2001:db8::5'; Cidr = '2001:db8::/32' }
    ) {
        Test-IpInCidrRange -IpAddress $Ip -Cidr $Cidr | Should -BeTrue
    }

    It 'Returns $false when "<Ip>" is outside "<Cidr>"' -ForEach @(
        @{ Ip = '203.0.114.5'; Cidr = '203.0.113.0/24' }
        @{ Ip = '192.168.1.127'; Cidr = '192.168.1.128/25' }
        @{ Ip = '11.1.2.3'; Cidr = '10.0.0.0/8' }
        @{ Ip = '2001:dba::5'; Cidr = '2001:db8::/32' }
    ) {
        Test-IpInCidrRange -IpAddress $Ip -Cidr $Cidr | Should -BeFalse
    }

    It 'Returns $false on address-family mismatch' {
        Test-IpInCidrRange -IpAddress '203.0.113.5' -Cidr '2001:db8::/32' | Should -BeFalse
    }

    It 'Returns $false (never throws) for malformed input' {
        Test-IpInCidrRange -IpAddress 'garbage' -Cidr 'also-garbage' | Should -BeFalse
    }
}

Describe 'Test-IpAllowListed' -Tags 'Unit' {
    It 'Returns $true when the IP matches an allow-list range' {
        Test-IpAllowListed -IpAddress '203.0.113.5' -AllowListRange @('10.0.0.0/8', '203.0.113.0/24') | Should -BeTrue
    }

    It 'Returns $false when the IP matches no range' {
        Test-IpAllowListed -IpAddress '198.51.100.7' -AllowListRange @('10.0.0.0/8', '203.0.113.0/24') | Should -BeFalse
    }

    It 'Returns $false for an empty or null allow-list' {
        Test-IpAllowListed -IpAddress '203.0.113.5' -AllowListRange @() | Should -BeFalse
        Test-IpAllowListed -IpAddress '203.0.113.5' -AllowListRange $null | Should -BeFalse
    }
}

Describe 'ConvertFrom-CanaryIncident' -Tags 'Unit' {
    BeforeAll {
        $entraTokenId = 'entra-token-abc'

        $entraIncident = [PSCustomObject]@{
            hash_id     = 'incident-1'
            description = [PSCustomObject]@{
                canarytoken = 'entra-token-abc'
                flock_name  = 'Citywest'
                description = 'Azure Entra ID Login'
                created_std = '2026-07-30 10:00:00 UTC'
                src_host    = '203.0.113.5'
                events      = @(
                    [PSCustomObject]@{ src_host = '203.0.113.5' }
                    [PSCustomObject]@{ src_host = '198.51.100.7' }
                )
            }
        }

        $otherTokenIncident = [PSCustomObject]@{
            hash_id     = 'incident-2'
            description = [PSCustomObject]@{
                canarytoken = 'some-other-token'
                src_host    = '203.0.113.9'
                events      = @()
            }
        }

        $nonTokenIncident = [PSCustomObject]@{
            hash_id     = 'incident-3'
            description = [PSCustomObject]@{
                logtype  = '4002'
                src_host = '203.0.113.11'
            }
        }
    }

    It 'Parses an Entra ID Login token incident and extracts unique source IPs' {
        $result = $entraIncident | ConvertFrom-CanaryIncident -EntraTokenId $entraTokenId
        $result | Should -Not -BeNullOrEmpty
        $result.IncidentId | Should -BeExactly 'incident-1'
        $result.FlockName | Should -BeExactly 'Citywest'
        $result.SourceIps | Should -HaveCount 2
        $result.SourceIps | Should -Contain '203.0.113.5'
        $result.SourceIps | Should -Contain '198.51.100.7'
    }

    It 'Ignores incidents belonging to a different canarytoken' {
        $otherTokenIncident | ConvertFrom-CanaryIncident -EntraTokenId $entraTokenId | Should -BeNullOrEmpty
    }

    It 'Ignores non-token incidents (no canarytoken field)' {
        $nonTokenIncident | ConvertFrom-CanaryIncident -EntraTokenId $entraTokenId | Should -BeNullOrEmpty
    }

    It 'Processes only the matching incident from a mixed batch' {
        $batch = @($entraIncident, $otherTokenIncident, $nonTokenIncident)
        $results = $batch | ConvertFrom-CanaryIncident -EntraTokenId $entraTokenId
        @($results) | Should -HaveCount 1
        $results.IncidentId | Should -BeExactly 'incident-1'
    }
}

Describe 'Confirm-CanaryConnectivity' -Tags 'Unit' {
    It 'Returns $true when the API result is success' {
        Mock -ModuleName UnipharSecurityAuth Invoke-RestMethod { @{ result = 'success' } }
        Confirm-CanaryConnectivity -ConsoleDomain 'abc.canary.tools' -AuthToken 'tok' | Should -BeTrue
    }

    It 'Returns $false when the API result is not success' {
        Mock -ModuleName UnipharSecurityAuth Invoke-RestMethod { @{ result = 'failure' } }
        Confirm-CanaryConnectivity -ConsoleDomain 'abc.canary.tools' -AuthToken 'tok' | Should -BeFalse
    }
}

Describe 'Get-CanaryIncident' -Tags 'Unit' {
    It 'Passes the canarytoken filter to the API and returns the incidents array' {
        Mock -ModuleName UnipharSecurityAuth Invoke-RestMethod {
            $script:capturedBody = $Body
            @{ incidents = @([PSCustomObject]@{ hash_id = 'x' }) }
        }
        $result = Get-CanaryIncident -ConsoleDomain 'abc.canary.tools' -AuthToken 'tok' -CanaryTokenId 'entra-token-abc'
        @($result) | Should -HaveCount 1
        Should -Invoke -ModuleName UnipharSecurityAuth Invoke-RestMethod -Times 1 -ParameterFilter {
            $Headers['X-Canary-Auth-Token'] -eq 'tok' -and $Body['canarytoken'] -eq 'entra-token-abc'
        }
    }
}

Describe 'Add-IpToRestrictedNamedLocation' -Tags 'Unit' {
    BeforeEach {
        $script:namedLocation = [PSCustomObject]@{
            Id                   = 'loc-1'
            DisplayName          = 'Restriced IPs'
            AdditionalProperties = @{
                '@odata.type' = '#microsoft.graph.ipNamedLocation'
                isTrusted     = $false
                ipRanges      = @(
                    @{ '@odata.type' = '#microsoft.graph.iPv4CidrRange'; cidrAddress = '203.0.113.0/24' }
                )
            }
        }
        Mock -ModuleName UnipharSecurityAuth Get-MgIdentityConditionalAccessNamedLocation { $script:namedLocation }
        Mock -ModuleName UnipharSecurityAuth Update-MgIdentityConditionalAccessNamedLocation { }
    }

    It 'Adds a new IP and updates the named location' {
        $added = Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '198.51.100.7'
        $added | Should -Contain '198.51.100.7/32'
        Should -Invoke -ModuleName UnipharSecurityAuth Update-MgIdentityConditionalAccessNamedLocation -Times 1
    }

    It 'Does not update when the IP range already exists (de-duplication)' {
        $added = Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '203.0.113.0/24'
        $added | Should -BeNullOrEmpty
        Should -Invoke -ModuleName UnipharSecurityAuth Update-MgIdentityConditionalAccessNamedLocation -Times 0
    }

    It 'Does NOT update the named location under -WhatIf (dry run)' {
        Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '198.51.100.7' -WhatIf
        Should -Invoke -ModuleName UnipharSecurityAuth Update-MgIdentityConditionalAccessNamedLocation -Times 0
    }

    It 'Throws when the named location does not exist' {
        Mock -ModuleName UnipharSecurityAuth Get-MgIdentityConditionalAccessNamedLocation { @() }
        { Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Missing' -IpAddress '198.51.100.7' } |
            Should -Throw '*not found*'
    }

    It 'Throws when adding would exceed the MaxRanges ceiling' {
        { Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '198.51.100.7' -MaxRanges 1 } |
            Should -Throw '*exceeding the 1 ceiling*'
    }

    It 'Throws (fails closed) when the named location has no isTrusted value' {
        [void]$script:namedLocation.AdditionalProperties.Remove('isTrusted')
        { Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '198.51.100.7' } |
            Should -Throw '*no isTrusted value*'
    }
}

Describe 'Revoke-UserAccess' -Tags 'Unit' {
    BeforeEach {
        Mock -ModuleName UnipharSecurityAuth Revoke-MgUserSignInSession { }
        Mock -ModuleName UnipharSecurityAuth Invoke-MgGraphRequest { }
    }

    It 'Revokes sessions and invalidates refresh tokens' {
        Revoke-UserAccess -UserId 'user-1'
        Should -Invoke -ModuleName UnipharSecurityAuth Revoke-MgUserSignInSession -Times 1
        Should -Invoke -ModuleName UnipharSecurityAuth Invoke-MgGraphRequest -Times 1 -ParameterFilter {
            $Uri -like '*invalidateAllRefreshTokens*'
        }
    }

    It 'Takes no action under -WhatIf (dry run)' {
        Revoke-UserAccess -UserId 'user-1' -WhatIf
        Should -Invoke -ModuleName UnipharSecurityAuth Revoke-MgUserSignInSession -Times 0
        Should -Invoke -ModuleName UnipharSecurityAuth Invoke-MgGraphRequest -Times 0
    }
}

Describe 'Get-SignInUserByIp' -Tags 'Unit' {
    It 'Throws on a non-IP value rather than building an OData filter' {
        { Get-SignInUserByIp -IpAddress "1.2.3.4' or startsWith(userPrincipalName,'admin" } |
            Should -Throw '*not a valid IP address*'
    }
}