tests/Test-PowerPlatformConnector.ps1

param(
    [string]$ModuleRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'modules')
)

. (Join-Path $PSScriptRoot '_TestHelpers.ps1')
$script:AssertionCount = 0

Import-IscModule -Path (Join-Path $ModuleRoot 'ISC.OperatorConsole.psm1') -Force
Import-IscModule -Path (Join-Path $ModuleRoot 'ISC.OperatorToolchain.psm1') -Force
Import-IscModule -Path (Join-Path $ModuleRoot 'ISC.PowerPlatformConnector.psm1') -Force
$module = Get-Module -Name 'ISC.PowerPlatformConnector'

$roles = @(Get-CopilotStudioRoleNames)
Assert-True ($roles -contains 'Global Discovery Service Role') 'the Global Discovery Service Role is required'
Assert-True ($roles -contains 'SailPoint BotReader') 'a custom BotReader role is required'
$privileges = @(Get-BotReadPrivilegeNames)
Assert-True ($privileges -contains 'prvReadbot') 'bot read is part of the custom role'
Assert-True ($privileges -contains 'prvReadbotcomponent') 'bot component read is part of the custom role'

# Dataverse is reached over its own Web API, so the whole surface is stubbed inside the module scope
# and the calls are recorded to assert what the fix actually does.
$script:Calls = [System.Collections.Generic.List[object]]::new()

function Reset-Stubs {
    param(
        [object]$ExistingUser,
        [object[]]$AssignedRoles = @(),
        [string[]]$GlobalBotReadRoleIds = @(),
        [string[]]$KnownRoles = @('Global Discovery Service Role')
    )

    $script:Calls = [System.Collections.Generic.List[object]]::new()
    & $module {
        param($existingUser, $assignedRoles, $globalReaders, $knownRoles, $calls)

        Set-Variable -Scope Script -Name ExistingUser -Value $existingUser
        Set-Variable -Scope Script -Name AssignedRoles -Value $assignedRoles
        Set-Variable -Scope Script -Name GlobalReaders -Value $globalReaders
        Set-Variable -Scope Script -Name KnownRoles -Value $knownRoles
        Set-Variable -Scope Script -Name Calls -Value $calls

        Set-Item function:script:Get-DataverseAccessToken { param($Resource, $AzPath) return 'stub-token' }

        Set-Item function:script:Get-DataverseApplicationUser {
            param($EnvironmentUrl, $ClientId, $Token)
            return $script:ExistingUser
        }

        Set-Item function:script:Get-DataverseRootBusinessUnit {
            param($EnvironmentUrl, $Token)
            return [PSCustomObject]@{ businessunitid = 'bu-1'; name = 'root' }
        }

        Set-Item function:script:New-DataverseApplicationUser {
            param($EnvironmentUrl, $ClientId, $BusinessUnitId, $Token)
            $script:Calls.Add(@{ Op = 'create-user'; Client = $ClientId; BusinessUnit = $BusinessUnitId })
            return [PSCustomObject]@{ systemuserid = 'su-1' }
        }

        Set-Item function:script:Get-DataverseAssignedRole {
            param($EnvironmentUrl, $SystemUserId, $Token)
            return @($script:AssignedRoles)
        }

        Set-Item function:script:Get-DataverseSecurityRole {
            param($EnvironmentUrl, $Name, $Token)
            if (@($script:KnownRoles) -contains $Name) {
                return [PSCustomObject]@{ roleid = "role-$Name"; name = $Name }
            }
            return $null
        }

        # Only roles explicitly listed grant organization-level bot read, which is what separates a
        # working role from Bot Viewer's user-scoped read.
        Set-Item function:script:Test-DataverseGlobalBotRead {
            param($EnvironmentUrl, $RoleId, $Token)
            return (@($script:GlobalReaders) -contains $RoleId)
        }

        Set-Item function:script:New-DataverseBotReaderRole {
            param($EnvironmentUrl, $BusinessUnitId, $Token, $Name)
            $script:Calls.Add(@{ Op = 'create-role'; BusinessUnit = $BusinessUnitId })
            return [PSCustomObject]@{ roleid = 'role-botreader'; name = 'SailPoint BotReader' }
        }

        Set-Item function:script:Add-DataverseSecurityRole {
            param($EnvironmentUrl, $SystemUserId, $RoleId, $Token)
            $script:Calls.Add(@{ Op = 'add-role'; Role = $RoleId })
        }
    } $ExistingUser $AssignedRoles $GlobalBotReadRoleIds $KnownRoles $script:Calls
}

# A missing application user is the real-world failure: Entra permissions are correct but the
# connector has no identity in Dataverse, so no agent is ever discovered.
Reset-Stubs -ExistingUser $null
$result = Set-CopilotStudioDataverseAccess -EnvironmentUrl 'https://org.crm.dynamics.com' -ClientId 'client-1' -EnvironmentName 'Contoso'
Assert-True $result.UserCreated 'a missing application user is created'
Assert-True ($null -eq $result.Error) 'configuring a healthy environment reports no error'
Assert-True ([bool](@($script:Calls) | Where-Object { $_.Op -eq 'create-user' -and $_.BusinessUnit -eq 'bu-1' })) 'the application user is bound to the root business unit'
Assert-True (@($result.RolesAdded) -contains 'Global Discovery Service Role') 'the discovery role is assigned'
Assert-True (@($result.RolesAdded) -contains 'SailPoint BotReader') 'the custom bot reader role is assigned'
Assert-True ([bool](@($script:Calls) | Where-Object { $_.Op -eq 'create-role' })) 'the custom role is created when no role grants organization-level bot read'

# The trap this guards against: a role is assigned and the API call succeeds, but the privilege is
# scoped to owned records, so the app reads zero agents. Presence of a role is never sufficient.
Reset-Stubs -ExistingUser ([PSCustomObject]@{ systemuserid = 'su-1' }) `
    -AssignedRoles @([PSCustomObject]@{ Name = 'Global Discovery Service Role'; RoleId = 'r-gds' },
                     [PSCustomObject]@{ Name = 'Bot Viewer'; RoleId = 'r-botviewer' }) `
    -GlobalBotReadRoleIds @()
$result = Set-CopilotStudioDataverseAccess -EnvironmentUrl 'https://org.crm.dynamics.com' -ClientId 'client-1'
Assert-True (@($result.RolesAdded) -contains 'SailPoint BotReader') 'Bot Viewer alone does not satisfy organization-level bot read'
Assert-True (-not (@($result.RolesAdded) -contains 'Global Discovery Service Role')) 'an already assigned discovery role is not reassigned'

# A role that does grant Global depth is honoured, whatever it is called.
Reset-Stubs -ExistingUser ([PSCustomObject]@{ systemuserid = 'su-1' }) `
    -AssignedRoles @([PSCustomObject]@{ Name = 'Global Discovery Service Role'; RoleId = 'r-gds' },
                     [PSCustomObject]@{ Name = 'Bot Contributor'; RoleId = 'r-botcontrib' }) `
    -GlobalBotReadRoleIds @('r-botcontrib')
$result = Set-CopilotStudioDataverseAccess -EnvironmentUrl 'https://org.crm.dynamics.com' -ClientId 'client-1'
Assert-Equal 0 (@($result.RolesAdded)).Count 'an existing role with organization-level bot read is reused'
Assert-Equal 0 (@(@($script:Calls) | Where-Object { $_.Op -eq 'add-role' })).Count 'a configured environment issues no writes'
Assert-True (-not $result.UserCreated) 'an existing application user is reused'

# WhatIf must report the same plan without writing.
Reset-Stubs -ExistingUser $null
$result = Set-CopilotStudioDataverseAccess -EnvironmentUrl 'https://org.crm.dynamics.com' -ClientId 'client-1' -WhatIf
Assert-True $result.UserCreated 'WhatIf reports that the application user would be created'
Assert-Equal 0 (@(@($script:Calls) | Where-Object { $_.Op -ne $null -and $_.Op -ne 'lookup' })).Count 'WhatIf writes nothing'

# An environment without the discovery role needs a manual equivalent, so the gap is reported.
Reset-Stubs -ExistingUser ([PSCustomObject]@{ systemuserid = 'su-1' }) -KnownRoles @()
$result = Set-CopilotStudioDataverseAccess -EnvironmentUrl 'https://org.crm.dynamics.com' -ClientId 'client-1' -WarningAction SilentlyContinue
Assert-True (@($result.RolesMissing) -contains 'Global Discovery Service Role') 'a role the environment does not define is reported as missing'

# One unreachable environment must not abort a run that already configured the application.
& $module {
    Set-Item function:script:Get-DataverseAccessToken { param($Resource, $AzPath) throw 'az login required' }
}
$result = Set-CopilotStudioDataverseAccess -EnvironmentUrl 'https://org.crm.dynamics.com' -ClientId 'client-1' -WarningAction SilentlyContinue
Assert-True ([bool]$result.Error) 'an unreachable environment is reported rather than thrown'
Assert-True ($result.Error -like '*az login*') 'the reported error explains the sign-in requirement'

Write-Host "PASS ($script:AssertionCount assertions)"