modules/Devolutions.CIEM.Graph/Tests/Unit/CIEMIdentityRiskSignals.Tests.ps1

BeforeAll {
    Remove-Module Devolutions.CIEM -Force -ErrorAction SilentlyContinue
    Import-Module (Join-Path $PSScriptRoot '..' '..' '..' '..' 'Devolutions.CIEM.psd1')
    Mock -ModuleName Devolutions.CIEM Write-CIEMLog {}

    # Create isolated test DB with base + azure + discovery + graph schemas
    New-CIEMDatabase -Path "$TestDrive/ciem.db"

    $azureSchema = Join-Path $PSScriptRoot '..' '..' '..' 'Azure' 'Infrastructure' 'Data' 'azure_schema.sql'
    Invoke-CIEMQuery -Query (Get-Content $azureSchema -Raw)

    $discoverySchema = Join-Path $PSScriptRoot '..' '..' '..' 'Azure' 'Discovery' 'Data' 'discovery_schema.sql'
    Invoke-CIEMQuery -Query (Get-Content $discoverySchema -Raw)

    $graphSchema = Join-Path $PSScriptRoot '..' '..' 'Data' 'graph_schema.sql'
    Invoke-CIEMQuery -Query (Get-Content $graphSchema -Raw)

    InModuleScope Devolutions.CIEM {
        $script:DatabasePath = "$TestDrive/ciem.db"
    }
}

Describe 'Get-CIEMIdentityRiskSignals' {

    Context 'Command structure' {

        It 'Is available as a public command' {
            Get-Command Get-CIEMIdentityRiskSignals -Module Devolutions.CIEM -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }

        It 'Has mandatory -PrincipalId parameter' {
            $param = (Get-Command Get-CIEMIdentityRiskSignals).Parameters['PrincipalId']
            $param | Should -Not -BeNullOrEmpty
            $mandatory = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] -and $_.Mandatory }
            $mandatory | Should -Not -BeNullOrEmpty
        }
    }

    Context 'User with direct and inherited roles' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # User node with recent sign-in (5 days ago via pre-computed properties)
            Save-CIEMGraphNode -Id 'user-mixed' -Kind 'EntraUser' -DisplayName 'Mixed Roles User' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    daysSinceSignIn = 5
                    lastSignIn = (Get-Date).AddDays(-5).ToString('o')
                    lastInteractiveSignIn = (Get-Date).AddDays(-5).ToString('o')
                } | ConvertTo-Json -Compress)

            # Group node for inherited role lookup
            Save-CIEMGraphNode -Id 'group-admins' -Kind 'EntraGroup' -DisplayName 'Cloud Admins' -Provider 'azure'

            # Scope target nodes (subscriptions/RGs must exist as nodes for FK)
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'
            Save-CIEMGraphNode -Id '/subscriptions/sub-1/resourceGroups/rg-web' -Kind 'AzureResourceGroup' -DisplayName 'rg-web' -Provider 'azure'

            # Direct: Reader (non-privileged)
            Save-CIEMGraphEdge -SourceId 'user-mixed' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Reader'; privileged = $false; scope = '/subscriptions/sub-1'; definition_id = 'role-reader' } | ConvertTo-Json -Compress)

            # Direct: Contributor on RG (non-privileged)
            Save-CIEMGraphEdge -SourceId 'user-mixed' -TargetId '/subscriptions/sub-1/resourceGroups/rg-web' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Contributor'; privileged = $false; scope = '/subscriptions/sub-1/resourceGroups/rg-web'; definition_id = 'role-contributor' } | ConvertTo-Json -Compress)

            # Inherited via group: Owner on subscription (privileged)
            Save-CIEMGraphEdge -SourceId 'user-mixed' -TargetId '/subscriptions/sub-1' -Kind 'InheritedRole' -Computed 1 `
                -Properties (@{ role_name = 'Owner'; privileged = $true; scope = '/subscriptions/sub-1'; definition_id = 'role-owner'; inherited_from = 'group-admins'; inherited_from_name = 'Cloud Admins' } | ConvertTo-Json -Compress)

            # MemberOf edge: user -> group (for InheritedFrom lookup)
            Save-CIEMGraphEdge -SourceId 'user-mixed' -TargetId 'group-admins' -Kind 'MemberOf' -Computed 0

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'user-mixed'
        }

        It 'Returns object with expected properties' {
            $script:result.PSObject.Properties.Name | Should -Contain 'Identity'
            $script:result.PSObject.Properties.Name | Should -Contain 'RoleAssignments'
            $script:result.PSObject.Properties.Name | Should -Contain 'RiskSignals'
            $script:result.PSObject.Properties.Name | Should -Contain 'InheritedRoles'
        }

        It 'RoleAssignments contains all 3 effective assignments' {
            @($script:result.RoleAssignments) | Should -HaveCount 3
        }

        It 'InheritedRoles identifies the group-inherited assignment' {
            @($script:result.InheritedRoles) | Should -HaveCount 1
            $script:result.InheritedRoles[0].RoleName | Should -Be 'Owner'
            $script:result.InheritedRoles[0].InheritedFrom | Should -Be 'Cloud Admins'
        }

        It 'RiskSignals includes group-inherited-privileged-role signal' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'group-inherited-privileged-role' }
            $signal | Should -Not -BeNullOrEmpty
            $signal.Severity | Should -Be 'High'
        }

        It 'Identity output includes correct fields from node properties' {
            $script:result.Identity.Id | Should -Be 'user-mixed'
            $script:result.Identity.DisplayName | Should -Be 'Mixed Roles User'
            $script:result.Identity.Type | Should -Be 'EntraUser'
            $script:result.Identity.AccountEnabled | Should -BeTrue
        }
    }

    Context 'Managed identity with hosting resource and public IP' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # Managed identity SP node
            Save-CIEMGraphNode -Id 'mi-vm-1' -Kind 'EntraManagedIdentity' -DisplayName 'vm-prod MI' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    servicePrincipalType = 'ManagedIdentity'
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            # Owner on subscription (privileged)
            Save-CIEMGraphEdge -SourceId 'mi-vm-1' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Owner'; privileged = $true; scope = '/subscriptions/sub-1'; definition_id = 'role-owner' } | ConvertTo-Json -Compress)

            # Hosting VM
            Save-CIEMGraphNode -Id '/subscriptions/sub-1/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-prod' `
                -Kind 'AzureVM' -DisplayName 'vm-prod' -Provider 'azure' -ResourceGroup 'rg-prod'

            # HasManagedIdentity edge: VM -> MI
            Save-CIEMGraphEdge -SourceId '/subscriptions/sub-1/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-prod' `
                -TargetId 'mi-vm-1' -Kind 'HasManagedIdentity' -Computed 1

            # NIC attached to the VM
            Save-CIEMGraphNode -Id '/subscriptions/sub-1/resourceGroups/rg-prod/providers/Microsoft.Network/networkInterfaces/vm-prod-nic' `
                -Kind 'AzureNIC' -DisplayName 'vm-prod-nic' -Provider 'azure' -ResourceGroup 'rg-prod'

            # AttachedTo edge: NIC -> VM
            Save-CIEMGraphEdge -SourceId '/subscriptions/sub-1/resourceGroups/rg-prod/providers/Microsoft.Network/networkInterfaces/vm-prod-nic' `
                -TargetId '/subscriptions/sub-1/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-prod' `
                -Kind 'AttachedTo' -Computed 1

            # Public IP
            Save-CIEMGraphNode -Id '/subscriptions/sub-1/resourceGroups/rg-prod/providers/Microsoft.Network/publicIPAddresses/pip-vm-prod' `
                -Kind 'AzurePublicIP' -DisplayName 'pip-vm-prod' -Provider 'azure' -ResourceGroup 'rg-prod'

            # HasPublicIP edge: NIC -> PublicIP
            Save-CIEMGraphEdge -SourceId '/subscriptions/sub-1/resourceGroups/rg-prod/providers/Microsoft.Network/networkInterfaces/vm-prod-nic' `
                -TargetId '/subscriptions/sub-1/resourceGroups/rg-prod/providers/Microsoft.Network/publicIPAddresses/pip-vm-prod' `
                -Kind 'HasPublicIP' -Computed 1

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'mi-vm-1'
        }

        It 'Returns HostingResource with VM details' {
            $script:result.HostingResource | Should -Not -BeNullOrEmpty
            $script:result.HostingResource.Name | Should -Be 'vm-prod'
            $script:result.HostingResource.Type | Should -Be 'AzureVM'
        }

        It 'HostingResource reports HasPublicIP as true' {
            $script:result.HostingResource.HasPublicIP | Should -BeTrue
        }

        It 'RiskSignals includes managed-identity-public-exposure' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'managed-identity-public-exposure' }
            $signal | Should -Not -BeNullOrEmpty
            $signal.Severity | Should -Be 'Critical'
        }
    }

    Context 'Dormant privileged permissions' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # User with daysSinceSignIn = 120 (pre-computed in properties)
            Save-CIEMGraphNode -Id 'user-dormant' -Kind 'EntraUser' -DisplayName 'Dormant Admin' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    daysSinceSignIn = 120
                    lastSignIn = '2025-11-01T12:00:00Z'
                    lastInteractiveSignIn = '2025-11-01T12:00:00Z'
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            # Privileged role
            Save-CIEMGraphEdge -SourceId 'user-dormant' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Owner'; privileged = $true; scope = '/subscriptions/sub-1'; definition_id = 'role-owner' } | ConvertTo-Json -Compress)

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'user-dormant'
        }

        It 'RiskSignals includes dormant-privileged-permissions' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal | Should -Not -BeNullOrEmpty
            $signal.Severity | Should -Be 'Critical'
        }

        It 'Dormant signal includes DaysSinceSignIn value' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal.DaysSinceSignIn | Should -Be 120
        }
    }

    Context 'Privileged identity with no sign-in data' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # User with no sign-in properties at all
            Save-CIEMGraphNode -Id 'user-no-signin' -Kind 'EntraUser' -DisplayName 'No SignIn User' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            # Privileged role
            Save-CIEMGraphEdge -SourceId 'user-no-signin' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Owner'; privileged = $true; scope = '/subscriptions/sub-1'; definition_id = 'role-owner' } | ConvertTo-Json -Compress)

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'user-no-signin'
        }

        It 'RiskSignals includes dormant-privileged-permissions' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal | Should -Not -BeNullOrEmpty
            $signal.Severity | Should -Be 'Critical'
        }

        It 'Description indicates no recorded sign-in activity' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal.Description | Should -BeLike '*no recorded sign-in activity*'
        }

        It 'DaysSinceSignIn is null when no sign-in data exists' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal.DaysSinceSignIn | Should -BeNullOrEmpty
        }
    }

    Context 'SP with only non-interactive sign-in is NOT dormant' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # SP node with recent non-interactive sign-in (pre-computed: daysSinceSignIn = 2)
            Save-CIEMGraphNode -Id 'sp-noninteractive' -Kind 'EntraServicePrincipal' -DisplayName 'Active SP' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    servicePrincipalType = 'Application'
                    daysSinceSignIn = 2
                    lastSignIn = (Get-Date).AddDays(-2).ToString('o')
                    lastNonInteractiveSignIn = (Get-Date).AddDays(-2).ToString('o')
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            # Non-privileged role (Contributor is not in the privileged list)
            Save-CIEMGraphEdge -SourceId 'sp-noninteractive' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Contributor'; privileged = $false; scope = '/subscriptions/sub-1'; definition_id = 'role-contributor' } | ConvertTo-Json -Compress)

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'sp-noninteractive'
        }

        It 'Does NOT trigger dormant-privileged-permissions signal' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal | Should -BeNullOrEmpty
        }

        It 'Identity output includes LastSignIn from non-interactive data' {
            $script:result.Identity.LastSignIn | Should -Not -BeNullOrEmpty
        }

        It 'Identity output includes LastInteractiveSignIn and LastNonInteractiveSignIn' {
            $script:result.Identity.PSObject.Properties.Name | Should -Contain 'LastInteractiveSignIn'
            $script:result.Identity.PSObject.Properties.Name | Should -Contain 'LastNonInteractiveSignIn'
            $script:result.Identity.LastInteractiveSignIn | Should -BeNullOrEmpty
            $script:result.Identity.LastNonInteractiveSignIn | Should -Not -BeNullOrEmpty
        }
    }

    Context 'SP with old interactive but recent non-interactive is NOT dormant' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # SP: interactive 120 days ago, non-interactive 5 days ago - daysSinceSignIn uses most recent = 5
            Save-CIEMGraphNode -Id 'sp-mixed-signin' -Kind 'EntraServicePrincipal' -DisplayName 'Mixed SignIn SP' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    servicePrincipalType = 'Application'
                    daysSinceSignIn = 5
                    lastSignIn = (Get-Date).AddDays(-5).ToString('o')
                    lastInteractiveSignIn = (Get-Date).AddDays(-120).ToString('o')
                    lastNonInteractiveSignIn = (Get-Date).AddDays(-5).ToString('o')
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            # Privileged role (Owner)
            Save-CIEMGraphEdge -SourceId 'sp-mixed-signin' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Owner'; privileged = $true; scope = '/subscriptions/sub-1'; definition_id = 'role-owner' } | ConvertTo-Json -Compress)

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'sp-mixed-signin'
        }

        It 'Does NOT trigger dormant-privileged-permissions signal' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal | Should -BeNullOrEmpty
        }

        It 'LastSignIn uses the more recent non-interactive date' {
            $lastSignIn = [datetime]$script:result.Identity.LastSignIn
            $daysSince = [math]::Floor(((Get-Date) - $lastSignIn).TotalDays)
            $daysSince | Should -BeLessOrEqual 10
        }

        It 'Both individual timestamps are populated' {
            $script:result.Identity.LastInteractiveSignIn | Should -Not -BeNullOrEmpty
            $script:result.Identity.LastNonInteractiveSignIn | Should -Not -BeNullOrEmpty
        }
    }

    Context 'Disabled account with active assignments' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            Save-CIEMGraphNode -Id 'user-disabled' -Kind 'EntraUser' -DisplayName 'Disabled User' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $false
                    daysSinceSignIn = 10
                    lastSignIn = (Get-Date).AddDays(-10).ToString('o')
                    lastInteractiveSignIn = (Get-Date).AddDays(-10).ToString('o')
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            Save-CIEMGraphEdge -SourceId 'user-disabled' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Contributor'; privileged = $false; scope = '/subscriptions/sub-1'; definition_id = 'role-contributor' } | ConvertTo-Json -Compress)

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'user-disabled'
        }

        It 'RiskSignals includes disabled-with-permissions' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'disabled-with-permissions' }
            $signal | Should -Not -BeNullOrEmpty
            $signal.Severity | Should -Be 'High'
        }
    }

    Context 'Clean identity with no risk signals' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            Save-CIEMGraphNode -Id 'user-clean' -Kind 'EntraUser' -DisplayName 'Clean User' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    daysSinceSignIn = 1
                    lastSignIn = (Get-Date).AddDays(-1).ToString('o')
                    lastInteractiveSignIn = (Get-Date).AddDays(-1).ToString('o')
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            Save-CIEMGraphEdge -SourceId 'user-clean' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Reader'; privileged = $false; scope = '/subscriptions/sub-1'; definition_id = 'role-reader' } | ConvertTo-Json -Compress)

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'user-clean'
        }

        It 'Returns empty RiskSignals array' {
            @($script:result.RiskSignals) | Should -HaveCount 0
        }

        It 'RoleAssignments still populated' {
            @($script:result.RoleAssignments) | Should -HaveCount 1
            $script:result.RoleAssignments[0].RoleName | Should -Be 'Reader'
        }

        It 'HostingResource is null for non-managed-identity' {
            $script:result.HostingResource | Should -BeNullOrEmpty
        }
    }

    Context 'Managed identity without public IP exposure' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # Managed identity node
            Save-CIEMGraphNode -Id 'mi-nopip' -Kind 'EntraManagedIdentity' -DisplayName 'private-vm MI' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    servicePrincipalType = 'ManagedIdentity'
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            # Owner role
            Save-CIEMGraphEdge -SourceId 'mi-nopip' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Owner'; privileged = $true; scope = '/subscriptions/sub-1'; definition_id = 'role-owner' } | ConvertTo-Json -Compress)

            # Hosting VM (no public IP)
            Save-CIEMGraphNode -Id '/subscriptions/sub-1/resourceGroups/rg-priv/providers/Microsoft.Compute/virtualMachines/vm-priv' `
                -Kind 'AzureVM' -DisplayName 'vm-priv' -Provider 'azure' -ResourceGroup 'rg-priv'

            Save-CIEMGraphEdge -SourceId '/subscriptions/sub-1/resourceGroups/rg-priv/providers/Microsoft.Compute/virtualMachines/vm-priv' `
                -TargetId 'mi-nopip' -Kind 'HasManagedIdentity' -Computed 1

            # NIC attached to VM but no public IP
            Save-CIEMGraphNode -Id '/subscriptions/sub-1/resourceGroups/rg-priv/providers/Microsoft.Network/networkInterfaces/vm-priv-nic' `
                -Kind 'AzureNIC' -DisplayName 'vm-priv-nic' -Provider 'azure' -ResourceGroup 'rg-priv'

            Save-CIEMGraphEdge -SourceId '/subscriptions/sub-1/resourceGroups/rg-priv/providers/Microsoft.Network/networkInterfaces/vm-priv-nic' `
                -TargetId '/subscriptions/sub-1/resourceGroups/rg-priv/providers/Microsoft.Compute/virtualMachines/vm-priv' `
                -Kind 'AttachedTo' -Computed 1

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'mi-nopip'
        }

        It 'Returns HostingResource with VM details' {
            $script:result.HostingResource | Should -Not -BeNullOrEmpty
            $script:result.HostingResource.Name | Should -Be 'vm-priv'
        }

        It 'HostingResource reports HasPublicIP as false' {
            $script:result.HostingResource.HasPublicIP | Should -BeFalse
        }

        It 'Does NOT include managed-identity-public-exposure signal' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'managed-identity-public-exposure' }
            $signal | Should -BeNullOrEmpty
        }
    }

    Context 'Multi-group InheritedFrom attribution picks correct group' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # User node
            Save-CIEMGraphNode -Id 'user-multi-group' -Kind 'EntraUser' -DisplayName 'Multi Group User' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    daysSinceSignIn = 3
                    lastSignIn = (Get-Date).AddDays(-3).ToString('o')
                    lastInteractiveSignIn = (Get-Date).AddDays(-3).ToString('o')
                } | ConvertTo-Json -Compress)

            # Two group nodes
            Save-CIEMGraphNode -Id 'group-readers' -Kind 'EntraGroup' -DisplayName 'Readers Group' -Provider 'azure'
            Save-CIEMGraphNode -Id 'group-owners' -Kind 'EntraGroup' -DisplayName 'Owners Group' -Provider 'azure'

            # Two scope targets (edges need unique source+target+kind)
            Save-CIEMGraphNode -Id '/subscriptions/sub-multi-1' -Kind 'AzureSubscription' -DisplayName 'Sub Multi 1' -Provider 'azure'
            Save-CIEMGraphNode -Id '/subscriptions/sub-multi-2' -Kind 'AzureSubscription' -DisplayName 'Sub Multi 2' -Provider 'azure'

            # MemberOf edges: user is member of BOTH groups
            Save-CIEMGraphEdge -SourceId 'user-multi-group' -TargetId 'group-readers' -Kind 'MemberOf' -Computed 0
            Save-CIEMGraphEdge -SourceId 'user-multi-group' -TargetId 'group-owners' -Kind 'MemberOf' -Computed 0

            # InheritedRole from Owners Group: Owner (privileged) on sub-1
            Save-CIEMGraphEdge -SourceId 'user-multi-group' -TargetId '/subscriptions/sub-multi-1' -Kind 'InheritedRole' -Computed 1 `
                -Properties (@{
                    role_name = 'Owner'
                    privileged = $true
                    scope = '/subscriptions/sub-multi-1'
                    definition_id = 'role-owner'
                    inherited_from = 'group-owners'
                    inherited_from_name = 'Owners Group'
                } | ConvertTo-Json -Compress)

            # InheritedRole from Readers Group: Reader (non-privileged) on sub-2
            Save-CIEMGraphEdge -SourceId 'user-multi-group' -TargetId '/subscriptions/sub-multi-2' -Kind 'InheritedRole' -Computed 1 `
                -Properties (@{
                    role_name = 'Reader'
                    privileged = $false
                    scope = '/subscriptions/sub-multi-2'
                    definition_id = 'role-reader'
                    inherited_from = 'group-readers'
                    inherited_from_name = 'Readers Group'
                } | ConvertTo-Json -Compress)

            $script:result = Get-CIEMIdentityRiskSignals -PrincipalId 'user-multi-group'
        }

        It 'Returns 2 inherited roles' {
            @($script:result.InheritedRoles) | Should -HaveCount 2
        }

        It 'Owner role shows InheritedFrom as Owners Group not Readers Group' {
            $ownerRole = $script:result.InheritedRoles | Where-Object { $_.RoleName -eq 'Owner' }
            $ownerRole.InheritedFrom | Should -Be 'Owners Group'
        }

        It 'Reader role shows InheritedFrom as Readers Group not Owners Group' {
            $readerRole = $script:result.InheritedRoles | Where-Object { $_.RoleName -eq 'Reader' }
            $readerRole.InheritedFrom | Should -Be 'Readers Group'
        }

        It 'Risk signal for group-inherited-privileged-role references Owners Group' {
            $signal = $script:result.RiskSignals | Where-Object { $_.Signal -eq 'group-inherited-privileged-role' }
            $signal | Should -Not -BeNullOrEmpty
            $signal.Description | Should -BeLike "*Owners Group*"
            $signal.Description | Should -Not -BeLike "*Readers Group*"
        }
    }

    Context 'Dormancy threshold boundary at exactly 90 days' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # User with daysSinceSignIn = 90 (exactly at the threshold)
            Save-CIEMGraphNode -Id 'user-boundary-90' -Kind 'EntraUser' -DisplayName 'Boundary 90 User' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    daysSinceSignIn = 90
                    lastSignIn = (Get-Date).AddDays(-90).ToString('o')
                    lastInteractiveSignIn = (Get-Date).AddDays(-90).ToString('o')
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            # Privileged role
            Save-CIEMGraphEdge -SourceId 'user-boundary-90' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Owner'; privileged = $true; scope = '/subscriptions/sub-1'; definition_id = 'role-owner' } | ConvertTo-Json -Compress)

            $script:result90 = Get-CIEMIdentityRiskSignals -PrincipalId 'user-boundary-90'
        }

        It 'Does NOT trigger dormant-privileged-permissions at exactly 90 days (threshold uses -gt not -ge)' {
            $signal = $script:result90.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal | Should -BeNullOrEmpty
        }
    }

    Context 'Dormancy threshold boundary at 91 days' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"

            # User with daysSinceSignIn = 91 (one day past the threshold)
            Save-CIEMGraphNode -Id 'user-boundary-91' -Kind 'EntraUser' -DisplayName 'Boundary 91 User' -Provider 'azure' `
                -Properties (@{
                    accountEnabled = $true
                    daysSinceSignIn = 91
                    lastSignIn = (Get-Date).AddDays(-91).ToString('o')
                    lastInteractiveSignIn = (Get-Date).AddDays(-91).ToString('o')
                } | ConvertTo-Json -Compress)

            # Scope target node
            Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure'

            # Privileged role
            Save-CIEMGraphEdge -SourceId 'user-boundary-91' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 `
                -Properties (@{ role_name = 'Owner'; privileged = $true; scope = '/subscriptions/sub-1'; definition_id = 'role-owner' } | ConvertTo-Json -Compress)

            $script:result91 = Get-CIEMIdentityRiskSignals -PrincipalId 'user-boundary-91'
        }

        It 'Triggers dormant-privileged-permissions at 91 days (one day past threshold)' {
            $signal = $script:result91.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal | Should -Not -BeNullOrEmpty
            $signal.Severity | Should -Be 'Critical'
        }

        It 'Dormant signal includes DaysSinceSignIn value of 91' {
            $signal = $script:result91.RiskSignals | Where-Object { $_.Signal -eq 'dormant-privileged-permissions' }
            $signal.DaysSinceSignIn | Should -Be 91
        }
    }

    Context 'Identity not found' {

        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"
        }

        It 'Throws when PrincipalId does not exist in graph nodes' {
            { Get-CIEMIdentityRiskSignals -PrincipalId 'nonexistent-id' } | Should -Throw
        }
    }
}