modules/Azure/Discovery/Tests/Unit/CIEMAzureEffectiveRoleAssignment.Tests.ps1

BeforeAll {
    Remove-Module Devolutions.CIEM -Force -ErrorAction SilentlyContinue
    Import-Module (Join-Path $PSScriptRoot '..' '..' '..' '..' '..' 'Devolutions.CIEM.psd1')
    Mock -ModuleName Devolutions.CIEM Write-CIEMLog {}
    $script:LegacyEraFixture = Get-Content (Join-Path $PSScriptRoot '..' 'Fixtures' 'legacy-era-output.json') -Raw | ConvertFrom-Json

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

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

    foreach ($schemaPath in @(
        (Join-Path $PSScriptRoot '..' '..' '..' 'Infrastructure' 'Data' 'azure_schema.sql'),
        (Join-Path $PSScriptRoot '..' '..' 'Data' 'discovery_schema.sql')
    )) {
        foreach ($statement in ((Get-Content $schemaPath -Raw) -split ';\s*\n' | Where-Object { $_.Trim() })) {
            Invoke-CIEMQuery -Query $statement.Trim() -AsNonQuery | Out-Null
        }
    }
}

Describe 'Effective Role Assignment CRUD' {

    Context 'Schema' {
        It 'azure_effective_role_assignments table exists after applying discovery_schema.sql' {
            $result = Invoke-CIEMQuery -Query "SELECT name FROM sqlite_master WHERE type='table' AND name='azure_effective_role_assignments'"
            $result | Should -Not -BeNullOrEmpty
        }

        It 'All 4 indexes exist' {
            $indexes = @(Invoke-CIEMQuery -Query "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='azure_effective_role_assignments'")
            $indexNames = $indexes.name
            $indexNames | Should -Contain 'idx_effective_ra_principal'
            $indexNames | Should -Contain 'idx_effective_ra_scope'
            $indexNames | Should -Contain 'idx_effective_ra_role_def'
            $indexNames | Should -Contain 'idx_effective_ra_principal_type'
        }
    }

    Context 'Command structure' {
        It 'New-CIEMAzureEffectiveRoleAssignment is available as a public command' {
            Get-Command -Module Devolutions.CIEM -Name New-CIEMAzureEffectiveRoleAssignment -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }

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

        It 'Update-CIEMAzureEffectiveRoleAssignment is available as a public command' {
            Get-Command -Module Devolutions.CIEM -Name Update-CIEMAzureEffectiveRoleAssignment -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }

        It 'Save-CIEMAzureEffectiveRoleAssignment is available as a public command' {
            Get-Command -Module Devolutions.CIEM -Name Save-CIEMAzureEffectiveRoleAssignment -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }

        It 'Remove-CIEMAzureEffectiveRoleAssignment is available as a public command' {
            Get-Command -Module Devolutions.CIEM -Name Remove-CIEMAzureEffectiveRoleAssignment -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }
    }

    Context 'New-CIEMAzureEffectiveRoleAssignment' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments"
        }

        It 'Creates record and returns CIEMAzureEffectiveRoleAssignment typed object with auto-incremented Id' {
            $result = New-CIEMAzureEffectiveRoleAssignment `
                -PrincipalId 'user-1' -PrincipalType 'User' `
                -OriginalPrincipalId 'user-1' -OriginalPrincipalType 'User' `
                -RoleDefinitionId 'role-def-1' -Scope '/subscriptions/sub-1' `
                -ComputedAt '2026-01-01T00:00:00Z'
            $result | Should -Not -BeNullOrEmpty
            $result.GetType().Name | Should -Be 'CIEMAzureEffectiveRoleAssignment'
            $result.Id | Should -BeOfType [int]
            $result.Id | Should -BeGreaterThan 0
        }

        It 'Consecutive creates return incrementing Ids' {
            $r1 = New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'a' -PrincipalType 'User' -OriginalPrincipalId 'a' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-1' -Scope '/sub/1' -ComputedAt '2026-01-01T00:00:00Z'
            $r2 = New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'b' -PrincipalType 'User' -OriginalPrincipalId 'b' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-1' -Scope '/sub/1' -ComputedAt '2026-01-01T00:00:00Z'
            $r2.Id | Should -BeGreaterThan $r1.Id
        }

        It 'Accepts -InputObject parameter set' {
            $obj = InModuleScope Devolutions.CIEM {
                $o = [CIEMAzureEffectiveRoleAssignment]::new()
                $o.PrincipalId = 'sp-1'
                $o.PrincipalType = 'ServicePrincipal'
                $o.OriginalPrincipalId = 'sp-1'
                $o.OriginalPrincipalType = 'ServicePrincipal'
                $o.RoleDefinitionId = 'rd-2'
                $o.Scope = '/subscriptions/sub-2'
                $o.ComputedAt = '2026-01-01T00:00:00Z'
                $o
            }
            $result = New-CIEMAzureEffectiveRoleAssignment -InputObject $obj
            $result | Should -Not -BeNullOrEmpty
            $result.PrincipalId | Should -Be 'sp-1'
        }

        It 'Throws on duplicate (principal_id, role_definition_id, scope, original_principal_id) UNIQUE violation' {
            $ts = '2026-01-01T00:00:00Z'
            New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'dup' -PrincipalType 'User' -OriginalPrincipalId 'dup' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-1' -Scope '/sub/1' -ComputedAt $ts
            { New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'dup' -PrincipalType 'User' -OriginalPrincipalId 'dup' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-1' -Scope '/sub/1' -ComputedAt $ts } | Should -Throw
        }

        It 'Defaults ComputedAt to current time when not provided' {
            $before = (Get-Date).ToString('o')
            $result = New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'def-ts' -PrincipalType 'User' -OriginalPrincipalId 'def-ts' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-1' -Scope '/sub/1'
            $result.ComputedAt | Should -Not -BeNullOrEmpty
        }

        It 'Returned Id matches the row fetched by Get-CIEMAzureEffectiveRoleAssignment (regression: connection-scoped last_insert_rowid)' {
            $result = New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'regress-1' -PrincipalType 'User' -OriginalPrincipalId 'regress-1' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-1' -Scope '/sub/1' -ComputedAt '2026-01-01T00:00:00Z'
            $fetched = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'regress-1'
            $result.Id | Should -Be $fetched[0].Id
            $result.Id | Should -BeGreaterThan 0
        }
    }

    Context 'Get-CIEMAzureEffectiveRoleAssignment' {
        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments"
            $ts = '2026-01-01T00:00:00Z'
            New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-1' -PrincipalType 'User' -OriginalPrincipalId 'user-1' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-owner' -RoleName 'Owner' -Scope '/subscriptions/sub-1' -ComputedAt $ts
            New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-1' -PrincipalType 'User' -OriginalPrincipalId 'group-1' -OriginalPrincipalType 'Group' -RoleDefinitionId 'rd-reader' -RoleName 'Reader' -Scope '/subscriptions/sub-2' -ComputedAt $ts
            New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'sp-1' -PrincipalType 'ServicePrincipal' -OriginalPrincipalId 'sp-1' -OriginalPrincipalType 'ServicePrincipal' -RoleDefinitionId 'rd-contrib' -RoleName 'Contributor' -Scope '/subscriptions/sub-1' -ComputedAt $ts
        }

        It 'Returns all when no filter' {
            $results = Get-CIEMAzureEffectiveRoleAssignment
            $results | Should -HaveCount 3
        }

        It 'Returns CIEMAzureEffectiveRoleAssignment typed objects' {
            $results = Get-CIEMAzureEffectiveRoleAssignment
            $results | ForEach-Object { $_.GetType().Name | Should -Be 'CIEMAzureEffectiveRoleAssignment' }
        }

        It 'Filters by -PrincipalId' {
            $results = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-1'
            $results | Should -HaveCount 2
        }

        It 'Filters by -OriginalPrincipalId' {
            $results = Get-CIEMAzureEffectiveRoleAssignment -OriginalPrincipalId 'group-1'
            $results | Should -HaveCount 1
            $results[0].PrincipalId | Should -Be 'user-1'
        }

        It 'Filters by -RoleDefinitionId' {
            $results = Get-CIEMAzureEffectiveRoleAssignment -RoleDefinitionId 'rd-owner'
            $results | Should -HaveCount 1
            $results[0].RoleName | Should -Be 'Owner'
        }

        It 'Filters by -Scope' {
            $results = Get-CIEMAzureEffectiveRoleAssignment -Scope '/subscriptions/sub-1'
            $results | Should -HaveCount 2
        }

        It 'Filters by -PrincipalType' {
            $results = Get-CIEMAzureEffectiveRoleAssignment -PrincipalType 'ServicePrincipal'
            $results | Should -HaveCount 1
            $results[0].PrincipalId | Should -Be 'sp-1'
        }

        It 'Filters by -PrincipalId and -RoleDefinitionId combined' {
            $results = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-1' -RoleDefinitionId 'rd-owner'
            $results | Should -HaveCount 1
            $results[0].Scope | Should -Be '/subscriptions/sub-1'
        }

        It 'Filters by -PrincipalType and -Scope combined' {
            $results = Get-CIEMAzureEffectiveRoleAssignment -PrincipalType 'User' -Scope '/subscriptions/sub-1'
            $results | Should -HaveCount 1
            $results[0].RoleName | Should -Be 'Owner'
        }

        It 'Returns empty array when no match' {
            $results = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'nonexistent'
            $results | Should -HaveCount 0
        }
    }

    Context 'Update-CIEMAzureEffectiveRoleAssignment' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments"
            $script:testRow = New-CIEMAzureEffectiveRoleAssignment `
                -PrincipalId 'upd-user' -PrincipalType 'User' `
                -PrincipalDisplayName 'Original Name' `
                -OriginalPrincipalId 'upd-user' -OriginalPrincipalType 'User' `
                -RoleDefinitionId 'rd-1' -RoleName 'OldRole' `
                -Scope '/subscriptions/sub-1' `
                -PermissionsJson '["read"]' `
                -ComputedAt '2026-01-01T00:00:00Z'
        }

        It 'Updates RoleName without overwriting other fields' {
            Update-CIEMAzureEffectiveRoleAssignment -Id $script:testRow.Id -RoleName 'NewRole'
            $result = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'upd-user'
            $result[0].RoleName | Should -Be 'NewRole'
            $result[0].PrincipalDisplayName | Should -Be 'Original Name'
            $result[0].PermissionsJson | Should -Be '["read"]'
        }

        It 'Updates PrincipalDisplayName without overwriting other fields' {
            Update-CIEMAzureEffectiveRoleAssignment -Id $script:testRow.Id -PrincipalDisplayName 'Updated Name'
            $result = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'upd-user'
            $result[0].PrincipalDisplayName | Should -Be 'Updated Name'
            $result[0].RoleName | Should -Be 'OldRole'
        }

        It 'Updates ComputedAt without overwriting other fields' {
            Update-CIEMAzureEffectiveRoleAssignment -Id $script:testRow.Id -ComputedAt '2026-03-01T00:00:00Z'
            $result = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'upd-user'
            $result[0].ComputedAt | Should -Be '2026-03-01T00:00:00Z'
            $result[0].RoleName | Should -Be 'OldRole'
        }

        It 'Returns nothing without -PassThru' {
            $result = Update-CIEMAzureEffectiveRoleAssignment -Id $script:testRow.Id -RoleName 'X'
            $result | Should -BeNullOrEmpty
        }

        It 'Returns updated object with -PassThru' {
            $result = Update-CIEMAzureEffectiveRoleAssignment -Id $script:testRow.Id -RoleName 'NewRole' -PassThru
            $result | Should -Not -BeNullOrEmpty
            $result.GetType().Name | Should -Be 'CIEMAzureEffectiveRoleAssignment'
            $result.RoleName | Should -Be 'NewRole'
        }

        It 'Accepts -InputObject for full object update' {
            $obj = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'upd-user'
            $obj = $obj | Select-Object -First 1
            $obj.RoleName = 'ViaInputObject'
            $obj.PrincipalDisplayName = 'IO Name'
            Update-CIEMAzureEffectiveRoleAssignment -InputObject $obj
            $result = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'upd-user'
            $result[0].RoleName | Should -Be 'ViaInputObject'
            $result[0].PrincipalDisplayName | Should -Be 'IO Name'
        }
    }

    Context 'Save-CIEMAzureEffectiveRoleAssignment' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments"
        }

        It 'INSERT OR REPLACE honors UNIQUE constraint (same tuple, different RoleName -> 1 row with updated RoleName)' {
            $ts = '2026-01-01T00:00:00Z'
            Save-CIEMAzureEffectiveRoleAssignment -PrincipalId 'save-u' -PrincipalType 'User' -OriginalPrincipalId 'save-u' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-1' -RoleName 'OldRole' -Scope '/sub/1' -ComputedAt $ts
            Save-CIEMAzureEffectiveRoleAssignment -PrincipalId 'save-u' -PrincipalType 'User' -OriginalPrincipalId 'save-u' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-1' -RoleName 'NewRole' -Scope '/sub/1' -ComputedAt $ts
            $results = Get-CIEMAzureEffectiveRoleAssignment
            $results | Should -HaveCount 1
            $results[0].RoleName | Should -Be 'NewRole'
        }

        It 'Accepts -InputObject via pipeline' {
            $obj = InModuleScope Devolutions.CIEM {
                $o = [CIEMAzureEffectiveRoleAssignment]::new()
                $o.PrincipalId = 'pipe-u'
                $o.PrincipalType = 'User'
                $o.OriginalPrincipalId = 'pipe-u'
                $o.OriginalPrincipalType = 'User'
                $o.RoleDefinitionId = 'rd-1'
                $o.Scope = '/sub/1'
                $o.ComputedAt = '2026-01-01T00:00:00Z'
                $o
            }
            $obj | Save-CIEMAzureEffectiveRoleAssignment
            $result = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'pipe-u'
            $result | Should -Not -BeNullOrEmpty
        }

        It 'Accepts null PrincipalDisplayName, RoleName, PermissionsJson without throwing' {
            { Save-CIEMAzureEffectiveRoleAssignment `
                -PrincipalId 'null-test' -PrincipalType 'User' `
                -OriginalPrincipalId 'null-test' -OriginalPrincipalType 'User' `
                -RoleDefinitionId 'rd-1' -Scope '/sub/1' `
                -ComputedAt '2026-01-01T00:00:00Z' } | Should -Not -Throw
            $result = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'null-test'
            $result | Should -HaveCount 1
            $result[0].PrincipalDisplayName | Should -BeNullOrEmpty
            $result[0].RoleName | Should -BeNullOrEmpty
            $result[0].PermissionsJson | Should -BeNullOrEmpty
        }

        It 'Accepts -Connection parameter for atomic transactions' {
            InModuleScope Devolutions.CIEM {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    Save-CIEMAzureEffectiveRoleAssignment `
                        -PrincipalId 'conn-test' -PrincipalType 'User' `
                        -OriginalPrincipalId 'conn-test' -OriginalPrincipalType 'User' `
                        -RoleDefinitionId 'rd-1' -Scope '/sub/1' `
                        -ComputedAt '2026-01-01T00:00:00Z' `
                        -Connection $conn
                } finally {
                    $conn.Dispose()
                }
            }
            $result = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'conn-test'
            $result | Should -HaveCount 1
        }
    }

    Context 'Remove-CIEMAzureEffectiveRoleAssignment' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments"
            $ts = '2026-01-01T00:00:00Z'
            $script:rmRow1 = New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'rm-u1' -PrincipalType 'User' -OriginalPrincipalId 'rm-u1' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-1' -Scope '/sub/1' -ComputedAt $ts
            New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'rm-u1' -PrincipalType 'User' -OriginalPrincipalId 'rm-u1' -OriginalPrincipalType 'User' -RoleDefinitionId 'rd-2' -Scope '/sub/2' -ComputedAt $ts
            New-CIEMAzureEffectiveRoleAssignment -PrincipalId 'rm-u2' -PrincipalType 'ServicePrincipal' -OriginalPrincipalId 'rm-u2' -OriginalPrincipalType 'ServicePrincipal' -RoleDefinitionId 'rd-1' -Scope '/sub/1' -ComputedAt $ts
        }

        It 'Removes by -Id' {
            Remove-CIEMAzureEffectiveRoleAssignment -Id $script:rmRow1.Id -Confirm:$false
            $result = Get-CIEMAzureEffectiveRoleAssignment -Id $script:rmRow1.Id
            $result | Should -BeNullOrEmpty
            Get-CIEMAzureEffectiveRoleAssignment | Should -HaveCount 2
        }

        It 'Removes by -PrincipalId (bulk - removes all rows for that principal, leaves others)' {
            Remove-CIEMAzureEffectiveRoleAssignment -PrincipalId 'rm-u1' -Confirm:$false
            $result = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'rm-u1'
            $result | Should -BeNullOrEmpty
            # rm-u2 still exists
            $remaining = Get-CIEMAzureEffectiveRoleAssignment
            $remaining | Should -HaveCount 1
            $remaining[0].PrincipalId | Should -Be 'rm-u2'
        }

        It 'Removes all records with -All switch' {
            Remove-CIEMAzureEffectiveRoleAssignment -All -Confirm:$false
            $results = Get-CIEMAzureEffectiveRoleAssignment
            $results | Should -HaveCount 0
        }

        It 'Removes via -InputObject' {
            $obj = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'rm-u2'
            Remove-CIEMAzureEffectiveRoleAssignment -InputObject $obj -Confirm:$false
            $result = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'rm-u2'
            $result | Should -BeNullOrEmpty
        }

        It 'Accepts -Connection parameter for atomic transactions' {
            InModuleScope Devolutions.CIEM {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    Remove-CIEMAzureEffectiveRoleAssignment -All -Confirm:$false -Connection $conn
                } finally {
                    $conn.Dispose()
                }
            }
            $results = Get-CIEMAzureEffectiveRoleAssignment
            $results | Should -HaveCount 0
        }

        It 'No-ops when Id does not exist' {
            { Remove-CIEMAzureEffectiveRoleAssignment -Id 99999 -Confirm:$false } | Should -Not -Throw
        }
    }

    Context 'InvokeCIEMAzureEffectiveRoleAssignmentBuild (private)' {
        BeforeAll {
            # --- Test data builders (must be in BeforeAll for Pester 5 Run phase) ---
            $script:roleDef1Props = @{
                roleName = 'Contributor'
                permissions = @(
                    @{
                        actions = @('*')
                        notActions = @('Microsoft.Authorization/*/Delete')
                        dataActions = @()
                        notDataActions = @()
                    }
                )
            } | ConvertTo-Json -Depth 5 -Compress

            $script:roleDef2Props = @{
                roleName = 'Reader'
                permissions = @(
                    @{
                        actions = @('*/read')
                        notActions = @()
                        dataActions = @()
                        notDataActions = @()
                    }
                )
            } | ConvertTo-Json -Depth 5 -Compress

            $script:directAssignmentProps = @{
                principalId = 'user-direct'
                principalType = 'User'
                roleDefinitionId = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'
                scope = '/subscriptions/sub-1'
            } | ConvertTo-Json -Compress

            $script:groupAssignmentProps = @{
                principalId = 'group-1'
                principalType = 'Group'
                roleDefinitionId = '/providers/Microsoft.Authorization/roleDefinitions/rd-reader'
                scope = '/subscriptions/sub-2'
            } | ConvertTo-Json -Compress
        }

        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments"
        }

        It 'Inserts direct assignment rows for non-group principals' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                roleDef1Props = $script:roleDef1Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = 'ra-1'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild `
                        -ArmResources $armResources `
                        -EntraResources @() `
                        -Relationships @() `
                        -Connection $conn `
                        -ComputedAt '2026-01-01T00:00:00Z'
                    $result | Should -Be 1
                } finally { $conn.Dispose() }
            }
            $rows = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-direct'
            $rows | Should -HaveCount 1
            $rows[0].PrincipalType | Should -Be 'User'
            $rows[0].OriginalPrincipalId | Should -Be 'user-direct'
        }

        It 'Expands group assignments to transitive members' {
            InModuleScope Devolutions.CIEM -Parameters @{
                groupProps = $script:groupAssignmentProps
                roleDef2Props = $script:roleDef2Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-reader'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef2Props }
                        [PSCustomObject]@{ Id = 'ra-grp'; Type = 'microsoft.authorization/roleassignments'; Properties = $groupProps }
                    )
                    $relationships = @(
                        [PSCustomObject]@{ SourceId = 'member-user'; SourceType = 'user'; TargetId = 'group-1'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                        [PSCustomObject]@{ SourceId = 'member-sp'; SourceType = 'servicePrincipal'; TargetId = 'group-1'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild `
                        -ArmResources $armResources `
                        -EntraResources @() `
                        -Relationships $relationships `
                        -Connection $conn `
                        -ComputedAt '2026-01-01T00:00:00Z'
                    # 2 expanded members + 1 group self-row = 3
                    $result | Should -Be 3
                } finally { $conn.Dispose() }
            }
            $allRows = Get-CIEMAzureEffectiveRoleAssignment
            $allRows | Should -HaveCount 3
            $memberRows = $allRows | Where-Object { $_.OriginalPrincipalType -eq 'Group' -and $_.PrincipalId -ne 'group-1' }
            $memberRows | Should -HaveCount 2
        }

        It 'Inserts only the group self-row when group has no transitive members' {
            InModuleScope Devolutions.CIEM -Parameters @{
                groupProps = $script:groupAssignmentProps
                roleDef2Props = $script:roleDef2Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-reader'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef2Props }
                        [PSCustomObject]@{ Id = 'ra-empty-grp'; Type = 'microsoft.authorization/roleassignments'; Properties = $groupProps }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild `
                        -ArmResources $armResources `
                        -EntraResources @() `
                        -Relationships @() `
                        -Connection $conn `
                        -ComputedAt '2026-01-01T00:00:00Z'
                    $result | Should -Be 1
                } finally { $conn.Dispose() }
            }
            $rows = Get-CIEMAzureEffectiveRoleAssignment
            $rows | Should -HaveCount 1
            $rows[0].PrincipalId | Should -Be 'group-1'
            $rows[0].OriginalPrincipalId | Should -Be 'group-1'
        }

        It 'Creates separate rows when same user gets same role through different groups' {
            $group2AssignProps = @{
                principalId = 'group-2'
                principalType = 'Group'
                roleDefinitionId = '/providers/Microsoft.Authorization/roleDefinitions/rd-reader'
                scope = '/subscriptions/sub-2'
            } | ConvertTo-Json -Compress

            InModuleScope Devolutions.CIEM -Parameters @{
                groupProps = $script:groupAssignmentProps
                group2Props = $group2AssignProps
                roleDef2Props = $script:roleDef2Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-reader'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef2Props }
                        [PSCustomObject]@{ Id = 'ra-grp1'; Type = 'microsoft.authorization/roleassignments'; Properties = $groupProps }
                        [PSCustomObject]@{ Id = 'ra-grp2'; Type = 'microsoft.authorization/roleassignments'; Properties = $group2Props }
                    )
                    $relationships = @(
                        [PSCustomObject]@{ SourceId = 'shared-user'; SourceType = 'user'; TargetId = 'group-1'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                        [PSCustomObject]@{ SourceId = 'shared-user'; SourceType = 'user'; TargetId = 'group-2'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild `
                        -ArmResources $armResources `
                        -EntraResources @() `
                        -Relationships $relationships `
                        -Connection $conn `
                        -ComputedAt '2026-01-01T00:00:00Z'
                } finally { $conn.Dispose() }
            }
            # shared-user appears twice (via group-1 and group-2) + 2 group self-rows = 4
            $userRows = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'shared-user'
            $userRows | Should -HaveCount 2
            ($userRows.OriginalPrincipalId | Sort-Object) | Should -Be @('group-1', 'group-2')
        }

        It 'Sets role_name from role definition lookup' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                roleDef1Props = $script:roleDef1Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = 'ra-rn'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                    )
                    InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                } finally { $conn.Dispose() }
            }
            $row = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-direct'
            $row[0].RoleName | Should -Be 'Contributor'
        }

        It 'Sets PermissionsJson from role definition permissions array' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                roleDef1Props = $script:roleDef1Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = 'ra-pj'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                    )
                    InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                } finally { $conn.Dispose() }
            }
            $row = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-direct'
            $row[0].PermissionsJson | Should -Not -BeNullOrEmpty
            $perms = $row[0].PermissionsJson | ConvertFrom-Json
            $perms[0].actions | Should -Contain '*'
        }

        It 'Sets PrincipalDisplayName from Entra resource DisplayName lookup' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                roleDef1Props = $script:roleDef1Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = 'ra-dn'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                    )
                    $entraResources = @(
                        [PSCustomObject]@{ Id = 'user-direct'; DisplayName = 'John Doe' }
                    )
                    InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources $entraResources -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                } finally { $conn.Dispose() }
            }
            $row = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-direct'
            $row[0].PrincipalDisplayName | Should -Be 'John Doe'
        }

        It 'Sets original_principal_id to the group id when expanding' {
            InModuleScope Devolutions.CIEM -Parameters @{
                groupProps = $script:groupAssignmentProps
                roleDef2Props = $script:roleDef2Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-reader'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef2Props }
                        [PSCustomObject]@{ Id = 'ra-orig'; Type = 'microsoft.authorization/roleassignments'; Properties = $groupProps }
                    )
                    $relationships = @(
                        [PSCustomObject]@{ SourceId = 'expanded-user'; SourceType = 'user'; TargetId = 'group-1'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                    )
                    InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships $relationships -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                } finally { $conn.Dispose() }
            }
            $row = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'expanded-user'
            $row[0].OriginalPrincipalId | Should -Be 'group-1'
            $row[0].OriginalPrincipalType | Should -Be 'Group'
        }

        It 'All effective rows share the same ComputedAt timestamp' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                groupProps = $script:groupAssignmentProps
                roleDef1Props = $script:roleDef1Props
                roleDef2Props = $script:roleDef2Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-reader'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef2Props }
                        [PSCustomObject]@{ Id = 'ra-ts1'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                        [PSCustomObject]@{ Id = 'ra-ts2'; Type = 'microsoft.authorization/roleassignments'; Properties = $groupProps }
                    )
                    $relationships = @(
                        [PSCustomObject]@{ SourceId = 'ts-member'; SourceType = 'user'; TargetId = 'group-1'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                    )
                    $fixedTimestamp = '2026-06-15T12:00:00Z'
                    InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships $relationships -Connection $conn -ComputedAt $fixedTimestamp
                } finally { $conn.Dispose() }
            }
            $rows = Get-CIEMAzureEffectiveRoleAssignment
            $rows | ForEach-Object { $_.ComputedAt | Should -Be '2026-06-15T12:00:00Z' }
        }

        It 'Returns count of rows inserted as [int]' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                groupProps = $script:groupAssignmentProps
                roleDef1Props = $script:roleDef1Props
                roleDef2Props = $script:roleDef2Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-reader'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef2Props }
                        [PSCustomObject]@{ Id = 'ra-cnt1'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                        [PSCustomObject]@{ Id = 'ra-cnt2'; Type = 'microsoft.authorization/roleassignments'; Properties = $groupProps }
                    )
                    $relationships = @(
                        [PSCustomObject]@{ SourceId = 'cnt-user'; SourceType = 'user'; TargetId = 'group-1'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                        [PSCustomObject]@{ SourceId = 'cnt-sp'; SourceType = 'servicePrincipal'; TargetId = 'group-1'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships $relationships -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                    # 1 direct + 2 expanded members + 1 group self = 4
                    $result | Should -BeOfType [int]
                    $result | Should -Be 4
                } finally { $conn.Dispose() }
            }
        }

        It 'Matches the committed legacy ERA fixture for a mixed direct and group expansion set' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                groupProps = $script:groupAssignmentProps
                roleDef1Props = $script:roleDef1Props
                roleDef2Props = $script:roleDef2Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-reader'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef2Props }
                        [PSCustomObject]@{ Id = 'ra-direct'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                        [PSCustomObject]@{ Id = 'ra-group'; Type = 'microsoft.authorization/roleassignments'; Properties = $groupProps }
                    )
                    $entraResources = @(
                        [pscustomobject]@{ Id = 'user-direct'; DisplayName = 'Direct User' }
                        [pscustomobject]@{ Id = 'member-user'; DisplayName = 'Member User' }
                        [pscustomobject]@{ Id = 'member-sp'; DisplayName = 'Member SP' }
                        [pscustomobject]@{ Id = 'group-1'; DisplayName = 'Cloud Admins' }
                    )
                    $relationships = @(
                        [PSCustomObject]@{ SourceId = 'member-user'; SourceType = 'user'; TargetId = 'group-1'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                        [PSCustomObject]@{ SourceId = 'member-sp'; SourceType = 'servicePrincipal'; TargetId = 'group-1'; TargetType = 'group'; Relationship = 'transitive_member_of' }
                    )
                    InvokeCIEMAzureEffectiveRoleAssignmentBuild `
                        -ArmResources $armResources `
                        -EntraResources $entraResources `
                        -Relationships $relationships `
                        -Connection $conn `
                        -ComputedAt '2026-01-01T00:00:00Z' | Out-Null
                } finally { $conn.Dispose() }
            }

            $normalizedRows = @(
                Get-CIEMAzureEffectiveRoleAssignment |
                    Sort-Object PrincipalId, OriginalPrincipalId |
                    ForEach-Object {
                        [pscustomobject]@{
                            PrincipalId = $_.PrincipalId
                            PrincipalType = $_.PrincipalType
                            PrincipalDisplayName = $_.PrincipalDisplayName
                            OriginalPrincipalId = $_.OriginalPrincipalId
                            OriginalPrincipalType = $_.OriginalPrincipalType
                            RoleName = $_.RoleName
                            Scope = $_.Scope
                        }
                    }
            )
            $normalizedFixture = @(
                $script:LegacyEraFixture |
                    Sort-Object PrincipalId, OriginalPrincipalId
            )

            ($normalizedRows | ConvertTo-Json -Depth 5) | Should -Be ($normalizedFixture | ConvertTo-Json -Depth 5)
        }

        # Error/skip paths
        It 'Skips role assignments with null Properties' {
            InModuleScope Devolutions.CIEM {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = 'ra-null'; Type = 'microsoft.authorization/roleassignments'; Properties = $null }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                    $result | Should -Be 0
                } finally { $conn.Dispose() }
            }
        }

        It 'Skips role assignments with invalid Properties JSON' {
            InModuleScope Devolutions.CIEM {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = 'ra-bad'; Type = 'microsoft.authorization/roleassignments'; Properties = 'not-valid-json{{{' }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                    $result | Should -Be 0
                } finally { $conn.Dispose() }
            }
        }

        It 'Skips role assignments missing principalId in properties' {
            InModuleScope Devolutions.CIEM {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $props = @{ roleDefinitionId = 'rd-1'; scope = '/sub/1' } | ConvertTo-Json -Compress
                    $armResources = @(
                        [PSCustomObject]@{ Id = 'ra-nopid'; Type = 'microsoft.authorization/roleassignments'; Properties = $props }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                    $result | Should -Be 0
                } finally { $conn.Dispose() }
            }
        }

        It 'Sets PrincipalDisplayName to null when principal not in Entra resources' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                roleDef1Props = $script:roleDef1Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = 'ra-nodn'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                    )
                    InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                } finally { $conn.Dispose() }
            }
            $row = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-direct'
            $row[0].PrincipalDisplayName | Should -BeNullOrEmpty
        }

        It 'Sets RoleName and PermissionsJson to null when role definition not found' {
            InModuleScope Devolutions.CIEM {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $props = @{ principalId = 'orphan-user'; principalType = 'User'; roleDefinitionId = '/unknown/role-def'; scope = '/sub/1' } | ConvertTo-Json -Compress
                    $armResources = @(
                        [PSCustomObject]@{ Id = 'ra-orphan'; Type = 'microsoft.authorization/roleassignments'; Properties = $props }
                    )
                    InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                } finally { $conn.Dispose() }
            }
            $row = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'orphan-user'
            $row | Should -HaveCount 1
            $row[0].RoleName | Should -BeNullOrEmpty
            $row[0].PermissionsJson | Should -BeNullOrEmpty
        }

        # Edge cases
        It 'Returns 0 when ArmResources contains no role assignments' {
            InModuleScope Devolutions.CIEM {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = 'vm-1'; Type = 'microsoft.compute/virtualmachines'; Properties = '{}' }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                    $result | Should -Be 0
                } finally { $conn.Dispose() }
            }
        }

        It 'Returns 0 when Relationships is empty but direct assignments still created' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                roleDef1Props = $script:roleDef1Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = 'ra-norel'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                    )
                    $result = InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                    $result | Should -Be 1
                } finally { $conn.Dispose() }
            }
        }

        It 'Creates rows with null display names when EntraResources is empty' {
            InModuleScope Devolutions.CIEM -Parameters @{
                directProps = $script:directAssignmentProps
                roleDef1Props = $script:roleDef1Props
            } {
                $conn = Open-PSUSQLiteConnection -Database $script:DatabasePath
                try {
                    $armResources = @(
                        [PSCustomObject]@{ Id = '/providers/Microsoft.Authorization/roleDefinitions/rd-contrib'; Type = 'microsoft.authorization/roledefinitions'; Properties = $roleDef1Props }
                        [PSCustomObject]@{ Id = 'ra-noent'; Type = 'microsoft.authorization/roleassignments'; Properties = $directProps }
                    )
                    InvokeCIEMAzureEffectiveRoleAssignmentBuild -ArmResources $armResources -EntraResources @() -Relationships @() -Connection $conn -ComputedAt '2026-01-01T00:00:00Z'
                } finally { $conn.Dispose() }
            }
            $row = Get-CIEMAzureEffectiveRoleAssignment -PrincipalId 'user-direct'
            $row | Should -HaveCount 1
            $row[0].PrincipalDisplayName | Should -BeNullOrEmpty
        }
    }
}