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

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

    # 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() })) {
            $trimmed = $statement.Trim()
            try {
                Invoke-CIEMQuery -Query $trimmed -AsNonQuery | Out-Null
            }
            catch {
                if ($trimmed -match 'ALTER\s+TABLE' -and $_.Exception.Message -match 'duplicate column') {
                    continue
                }
                throw
            }
        }
    }
}

Describe 'Resource Relationship CRUD' {

    Context 'New-CIEMAzureResourceRelationship' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM azure_resource_relationships"
        }

        It 'Creates a relationship with auto-generated Id' {
            $result = New-CIEMAzureResourceRelationship `
                -SourceId 'user-1' -SourceType 'User' `
                -TargetId 'group-1' -TargetType 'Group' `
                -Relationship 'MemberOf' `
                -CollectedAt (Get-Date).ToString('o')
            $result | Should -Not -BeNullOrEmpty
            $result.Id | Should -BeOfType [int]
            $result.Id | Should -BeGreaterThan 0
        }

        It 'Consecutive creates return incrementing Ids' {
            $r1 = New-CIEMAzureResourceRelationship -SourceId 'a' -SourceType 'User' -TargetId 'b' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt (Get-Date).ToString('o')
            $r2 = New-CIEMAzureResourceRelationship -SourceId 'c' -SourceType 'User' -TargetId 'd' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt (Get-Date).ToString('o')
            $r2.Id | Should -BeGreaterThan $r1.Id
        }

        It 'Accepts -InputObject parameter set' {
            $obj = InModuleScope Devolutions.CIEM {
                $o = [CIEMAzureResourceRelationship]::new()
                $o.SourceId = 'sp-1'
                $o.SourceType = 'ServicePrincipal'
                $o.TargetId = 'role-1'
                $o.TargetType = 'DirectoryRole'
                $o.Relationship = 'HasRole'
                $o.CollectedAt = (Get-Date).ToString('o')
                $o
            }
            $result = New-CIEMAzureResourceRelationship -InputObject $obj
            $result | Should -Not -BeNullOrEmpty
            $result.SourceId | Should -Be 'sp-1'
        }

        It 'Mandatory: SourceId, SourceType, TargetId, TargetType, Relationship, CollectedAt' {
            { New-CIEMAzureResourceRelationship -SourceId 'x' -SourceType 'User' -TargetId 'y' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt (Get-Date).ToString('o') } | Should -Not -Throw
            # Missing mandatory should throw
            { New-CIEMAzureResourceRelationship -SourceId 'x' } | Should -Throw
        }

        It 'Throws on duplicate (source_id, target_id, relationship) UNIQUE violation' {
            $ts = (Get-Date).ToString('o')
            New-CIEMAzureResourceRelationship -SourceId 'dup-s' -SourceType 'User' -TargetId 'dup-t' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt $ts
            { New-CIEMAzureResourceRelationship -SourceId 'dup-s' -SourceType 'User' -TargetId 'dup-t' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt $ts } | Should -Throw
        }
    }

    Context 'Get-CIEMAzureResourceRelationship' {
        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM azure_resource_relationships"
            $ts = (Get-Date).ToString('o')
            # Seed test relationships
            New-CIEMAzureResourceRelationship -SourceId 'user-1' -SourceType 'User' -TargetId 'group-1' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt $ts
            New-CIEMAzureResourceRelationship -SourceId 'user-1' -SourceType 'User' -TargetId 'role-1' -TargetType 'DirectoryRole' -Relationship 'HasRole' -CollectedAt $ts
            New-CIEMAzureResourceRelationship -SourceId 'sp-1' -SourceType 'ServicePrincipal' -TargetId 'group-1' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt $ts
            New-CIEMAzureResourceRelationship -SourceId 'vm-1' -SourceType 'VirtualMachine' -TargetId 'nsg-1' -TargetType 'NetworkSecurityGroup' -Relationship 'AttachedTo' -CollectedAt $ts
        }

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

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

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

        It 'Filters by -TargetId' {
            $results = Get-CIEMAzureResourceRelationship -TargetId 'group-1'
            $results | Should -HaveCount 2
        }

        It 'Filters by -Relationship' {
            $results = Get-CIEMAzureResourceRelationship -Relationship 'MemberOf'
            $results | Should -HaveCount 2
        }

        It 'Filters by -SourceType and -TargetType' {
            $results = Get-CIEMAzureResourceRelationship -SourceType 'User' -TargetType 'DirectoryRole'
            $results | Should -HaveCount 1
            $results[0].Relationship | Should -Be 'HasRole'
        }
    }

    Context 'Update-CIEMAzureResourceRelationship' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM azure_resource_relationships"
            $script:testRel = New-CIEMAzureResourceRelationship -SourceId 'upd-s' -SourceType 'User' -TargetId 'upd-t' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt '2026-01-01T00:00:00Z'
        }

        It 'Updates Relationship field via partial update' {
            Update-CIEMAzureResourceRelationship -Id $script:testRel.Id -Relationship 'OwnerOf'
            $result = Get-CIEMAzureResourceRelationship -SourceId 'upd-s'
            $result.Relationship | Should -Be 'OwnerOf'
        }

        It 'Updates CollectedAt without overwriting other fields' {
            Update-CIEMAzureResourceRelationship -Id $script:testRel.Id -CollectedAt '2026-03-01T00:00:00Z'
            $result = Get-CIEMAzureResourceRelationship -SourceId 'upd-s'
            $result.CollectedAt | Should -Be '2026-03-01T00:00:00Z'
            $result.SourceType | Should -Be 'User'
            $result.TargetId | Should -Be 'upd-t'
        }

        It 'Returns nothing without -PassThru' {
            $result = Update-CIEMAzureResourceRelationship -Id $script:testRel.Id -Relationship 'Changed'
            $result | Should -BeNullOrEmpty
        }

        It 'Returns updated object with -PassThru' {
            $result = Update-CIEMAzureResourceRelationship -Id $script:testRel.Id -Relationship 'OwnerOf' -PassThru
            $result | Should -Not -BeNullOrEmpty
            $result.GetType().Name | Should -Be 'CIEMAzureResourceRelationship'
            $result.Relationship | Should -Be 'OwnerOf'
        }

        It 'Accepts -InputObject for full object update' {
            $obj = Get-CIEMAzureResourceRelationship -SourceId 'upd-s'
            $obj = $obj | Select-Object -First 1
            $obj.Relationship = 'AdminOf'
            $obj.TargetType = 'Application'
            Update-CIEMAzureResourceRelationship -InputObject $obj
            $result = Get-CIEMAzureResourceRelationship -SourceId 'upd-s'
            $result.Relationship | Should -Be 'AdminOf'
            $result.TargetType | Should -Be 'Application'
        }
    }

    Context 'Save-CIEMAzureResourceRelationship' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM azure_resource_relationships"
        }

        It 'INSERT OR REPLACE honors UNIQUE constraint (updates on conflict)' {
            $ts = (Get-Date).ToString('o')
            Save-CIEMAzureResourceRelationship -SourceId 'save-s' -SourceType 'User' -TargetId 'save-t' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt $ts
            Save-CIEMAzureResourceRelationship -SourceId 'save-s' -SourceType 'UserV2' -TargetId 'save-t' -TargetType 'GroupV2' -Relationship 'MemberOf' -CollectedAt $ts
            # UNIQUE(source_id, target_id, relationship) — should be 1 row, not 2
            $results = Get-CIEMAzureResourceRelationship
            $results | Should -HaveCount 1
            $results[0].SourceType | Should -Be 'UserV2'
        }

        It 'Accepts -InputObject via pipeline' {
            $obj = InModuleScope Devolutions.CIEM {
                $o = [CIEMAzureResourceRelationship]::new()
                $o.SourceId = 'pipe-s'
                $o.SourceType = 'ServicePrincipal'
                $o.TargetId = 'pipe-t'
                $o.TargetType = 'DirectoryRole'
                $o.Relationship = 'HasRole'
                $o.CollectedAt = (Get-Date).ToString('o')
                $o
            }
            $obj | Save-CIEMAzureResourceRelationship
            $result = Get-CIEMAzureResourceRelationship -SourceId 'pipe-s'
            $result | Should -Not -BeNullOrEmpty
        }
    }

    Context 'Remove-CIEMAzureResourceRelationship' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM azure_resource_relationships"
            $ts = (Get-Date).ToString('o')
            $script:rmRel1 = New-CIEMAzureResourceRelationship -SourceId 'rm-s1' -SourceType 'User' -TargetId 'rm-t1' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt $ts
            New-CIEMAzureResourceRelationship -SourceId 'rm-s2' -SourceType 'User' -TargetId 'rm-t2' -TargetType 'Group' -Relationship 'MemberOf' -CollectedAt $ts
            New-CIEMAzureResourceRelationship -SourceId 'rm-s3' -SourceType 'ServicePrincipal' -TargetId 'rm-t3' -TargetType 'DirectoryRole' -Relationship 'HasRole' -CollectedAt $ts
        }

        It 'Removes by -Id (integer)' {
            Remove-CIEMAzureResourceRelationship -Id $script:rmRel1.Id -Confirm:$false
            $result = Get-CIEMAzureResourceRelationship -SourceId 'rm-s1'
            $result | Should -BeNullOrEmpty
            Get-CIEMAzureResourceRelationship | Should -HaveCount 2
        }

        It 'Removes by combo: -SourceId + -TargetId + -Relationship' {
            Remove-CIEMAzureResourceRelationship -SourceId 'rm-s2' -TargetId 'rm-t2' -Relationship 'MemberOf' -Confirm:$false
            $result = Get-CIEMAzureResourceRelationship -SourceId 'rm-s2'
            $result | Should -BeNullOrEmpty
        }

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

        It 'Removes via -InputObject' {
            $obj = Get-CIEMAzureResourceRelationship -SourceId 'rm-s3'
            Remove-CIEMAzureResourceRelationship -InputObject $obj -Confirm:$false
            $result = Get-CIEMAzureResourceRelationship -SourceId 'rm-s3'
            $result | Should -BeNullOrEmpty
        }

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