tests/Test-EntraConnector.ps1

param(
    [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'modules/ISC.EntraConnector.psm1')
)

$ErrorActionPreference = 'Stop'
$script:AssertionCount = 0

function Assert-True {
    param([bool]$Condition, [string]$Message)
    $script:AssertionCount++
    if (-not $Condition) { throw "Assertion failed: $Message" }
}

function Assert-Equal {
    param($Expected, $Actual, [string]$Message)
    $script:AssertionCount++
    if ($Expected -ne $Actual) {
        throw "Assertion failed: $Message. Expected '$Expected', got '$Actual'."
    }
}

# The connector module writes progress through the console helpers, so they have to be loaded even
# for the assertions that never touch a tenant.
Import-Module (Join-Path (Split-Path -Parent $ModulePath) 'ISC.OperatorConsole.psm1') -Force -WarningAction SilentlyContinue
Import-Module $ModulePath -Force -WarningAction SilentlyContinue
Initialize-OperatorConsole
Initialize-EntraConnectorData
$catalog = Get-EntraConnectorCatalog

Assert-True ($catalog.CorePermissions.Count -gt 0) 'core permissions load'
Assert-True ($catalog.FeaturePacks.Contains('AccessPackages')) 'feature packs include AccessPackages'
$packLabels = @($catalog.FeaturePacks.Keys | ForEach-Object { [string]$catalog.FeaturePacks[$_].Label })
Assert-Equal (($packLabels | Sort-Object) -join '|') ($packLabels -join '|') 'feature packs are ordered by label'

$perms = @(Get-SelectedPermissions -Mode 'Granular' -FeatureNames @('MfaManagement'))
Assert-True ($perms.Count -gt $catalog.CorePermissions.Count) 'feature pack expands permissions'

$directory = @(Get-SelectedPermissions -Mode 'Directory' -FeatureNames @())
Assert-True ($directory.Count -lt $perms.Count) 'directory mode is coarser than granular plus features'

# Agent 365 is delegated-only: it must not add application permissions, and its scopes must not
# leak into packs that were not selected.
$withAgent365 = @(Get-SelectedPermissions -Mode 'Granular' -FeatureNames @('Agent365'))
Assert-Equal $catalog.CorePermissions.Count $withAgent365.Count 'Agent365 adds no application permissions'

$delegated = @(@(Get-SelectedDelegatedPermissions -FeatureNames @('Agent365')).Value)
Assert-True ($delegated -contains 'CopilotPackages.Read.All') 'Agent365 requests the catalog read scope'
Assert-True ($delegated -contains 'offline_access') 'Agent365 requests offline_access so a refresh token is returned'
Assert-Equal 0 (@(Get-SelectedDelegatedPermissions -FeatureNames @('MfaManagement')).Count) 'packs without delegated permissions stay application-only'

$scopes = @(Get-Agent365Scopes)
Assert-True ($scopes -contains 'offline_access') 'authorization request asks for offline_access'
Assert-True ($scopes -contains 'https://graph.microsoft.com/.default') 'authorization request asks for the Graph default scope'

$authUrl = Get-EntraAuthorizationUrl -TenantId 'contoso.onmicrosoft.com' -ClientId '11111111-2222-3333-4444-555555555555' `
    -RedirectUri 'http://localhost:8400/' -Scopes $scopes -State 'abc123'
Assert-True ($authUrl -like 'https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/authorize?*') 'authorization URL targets the tenant endpoint'
Assert-True ($authUrl -like '*response_type=code*') 'authorization URL requests an authorization code'
Assert-True ($authUrl -like '*redirect_uri=http%3A%2F%2Flocalhost%3A8400%2F*') 'authorization URL carries the encoded redirect URI'
Assert-True ($authUrl -like '*state=abc123*') 'authorization URL carries the state value'

# Graph writes are stubbed so the manifest and consent merging can be asserted without a tenant.
# The Graph cmdlets are not imported here, so these definitions are what the module resolves.
$script:LastUpdate = $null
function Update-MgApplication {
    param($ApplicationId, $RequiredResourceAccess, $Web)
    $script:LastUpdate = @{ RequiredResourceAccess = $RequiredResourceAccess; Web = $Web }
}

$script:GraphCalls = [System.Collections.Generic.List[object]]::new()
$script:GrantList = @{ value = @() }
function Invoke-MgGraphRequest {
    param($Method, $Uri, $Body, $OutputType, $ErrorAction)
    $script:GraphCalls.Add(@{ Method = $Method; Body = $Body })
    if ($Method -eq 'GET') { return [pscustomobject]$script:GrantList }
    return @{}
}

$graphAppId = '00000003-0000-0000-c000-000000000000'
$app = [pscustomobject]@{
    Id                     = 'app-object-id'
    AppId                  = '11111111-2222-3333-4444-555555555555'
    RequiredResourceAccess = @(
        [pscustomobject]@{ ResourceAppId = '99999999-0000-0000-0000-000000000000'; ResourceAccess = @([pscustomobject]@{ Id = 'other'; Type = 'Role' }) }
    )
    Web                    = [pscustomobject]@{ RedirectUris = @() }
}

Set-RequiredResourceAccess -Application $app `
    -RolesByResource @{ Graph = @([pscustomobject]@{ Id = 'role-1'; Value = 'User.Read.All' }) } `
    -ScopesByResource @{ Graph = @([pscustomobject]@{ Id = 'scope-1'; Value = 'CopilotPackages.Read.All' }) }
$written = @($script:LastUpdate.RequiredResourceAccess)
Assert-Equal 2 $written.Count 'unmanaged resource entries survive a manifest update'
$graphAccess = @(($written | Where-Object { $_.ResourceAppId -eq $graphAppId }).ResourceAccess)
Assert-Equal 2 $graphAccess.Count 'application and delegated permissions share one resource entry'
Assert-True (@($graphAccess.Type) -contains 'Scope') 'delegated permissions are written as Scope'
Assert-True (@($graphAccess.Type) -contains 'Role') 'application permissions are still written as Role'

Set-RequiredResourceAccess -Application $app -RolesByResource @{} `
    -ScopesByResource @{ Graph = @([pscustomobject]@{ Id = 'scope-1'; Value = 'CopilotPackages.Read.All' }) }
$graphAccess = @((@($script:LastUpdate.RequiredResourceAccess) | Where-Object { $_.ResourceAppId -eq $graphAppId }).ResourceAccess)
Assert-Equal 1 $graphAccess.Count 'a resource with only delegated permissions is still written'

$script:LastUpdate = $null
$null = Set-EntraRedirectUri -Application ([pscustomobject]@{ Id = 'a'; Web = [pscustomobject]@{ RedirectUris = @('http://localhost:8400/') } }) -RedirectUri 'http://localhost:8400/'
Assert-True ($null -eq $script:LastUpdate) 'an already registered redirect URI is left alone'

$null = Set-EntraRedirectUri -Application ([pscustomobject]@{ Id = 'a' }) -RedirectUri 'http://localhost:8400/'
Assert-Equal 1 (@($script:LastUpdate.Web.RedirectUris).Count) 'an application with no Web section gets one'

$sp = [pscustomobject]@{ Id = 'client-sp' }
$graphSp = [pscustomobject]@{ Id = 'graph-sp' }
$consentScopes = @(
    [pscustomobject]@{ Id = 's1'; Value = 'CopilotPackages.Read.All' }
    [pscustomobject]@{ Id = 's2'; Value = 'offline_access' }
)

$script:GrantList = @{ value = @([pscustomobject]@{ id = 'grant-1'; resourceId = 'graph-sp'; consentType = 'AllPrincipals'; scope = 'offline_access User.Read' }) }
$null = Grant-DelegatedConsent -ServicePrincipal $sp -ResourceServicePrincipal $graphSp -Scopes $consentScopes
$patch = @($script:GraphCalls | Where-Object { $_.Method -eq 'PATCH' })
Assert-Equal 1 $patch.Count 'an existing tenant-wide grant is patched, not duplicated'
$merged = @([string]$patch[0].Body.scope -split ' ')
Assert-True ($merged -contains 'User.Read') 'consent already given to other scopes is preserved'
Assert-Equal 1 (@($merged | Where-Object { $_ -eq 'offline_access' }).Count) 'a scope consented twice is written once'

$script:GraphCalls.Clear()
$script:GrantList = @{ value = @([pscustomobject]@{ id = 'grant-1'; resourceId = 'graph-sp'; consentType = 'AllPrincipals'; scope = 'offline_access CopilotPackages.Read.All' }) }
$null = Grant-DelegatedConsent -ServicePrincipal $sp -ResourceServicePrincipal $graphSp -Scopes $consentScopes
Assert-Equal 0 (@($script:GraphCalls | Where-Object { $_.Method -ne 'GET' }).Count) 'a fully consented grant is not rewritten'

# A secret created seconds earlier can still be unknown to the token endpoint (AADSTS7000215), and
# that used to lose both the secret and the browser round trip. The stub lives in module scope
# because Invoke-RestMethod resolves there as a cmdlet, which a global function would not shadow.
$module = Get-Module -Name 'ISC.EntraConnector'
& $module {
    Set-Variable -Name TokenCalls -Scope Script -Value ([System.Collections.Generic.List[object]]::new())
    Set-Variable -Name TokenFailures -Scope Script -Value 0
    Set-Variable -Name TokenError -Scope Script -Value 'AADSTS7000215: Invalid client secret provided.'
    Set-Item -Path 'function:script:Invoke-RestMethod' -Value {
        param($Method, $Uri, $Body, $ErrorAction)
        $script:TokenCalls.Add($Body)
        if ($script:TokenCalls.Count -le $script:TokenFailures) { throw $script:TokenError }
        return [pscustomobject]@{ refresh_token = 'refresh-value' }
    }
}

$ready = & $module {
    $script:TokenCalls.Clear()
    $script:TokenFailures = 2
    Wait-EntraClientSecretReady -TenantId 't' -ClientId 'c' -ClientSecret 's' -TimeoutSeconds 30 -DelaySeconds 0
}
Assert-True $ready 'a secret that is still replicating is waited out'
$probes = & $module { @($script:TokenCalls) }
Assert-Equal 3 $probes.Count 'the readiness probe keeps polling until the secret works'
Assert-Equal 'client_credentials' $probes[0].grant_type 'the readiness probe never spends an authorization code'

$timedOut = & $module {
    $script:TokenCalls.Clear()
    $script:TokenFailures = 99
    Wait-EntraClientSecretReady -TenantId 't' -ClientId 'c' -ClientSecret 's' -TimeoutSeconds 0 -DelaySeconds 0
}
Assert-True (-not $timedOut) 'waiting gives up instead of hanging forever'

$ready = & $module {
    $script:TokenCalls.Clear()
    $script:TokenFailures = 1
    $script:TokenError = 'AADSTS700016: application not found in the directory'
    Wait-EntraClientSecretReady -TenantId 't' -ClientId 'c' -ClientSecret 's' -TimeoutSeconds 30 -DelaySeconds 0
}
Assert-True $ready 'an unrelated failure is left for the exchange to report in context'
Assert-Equal 1 (& $module { @($script:TokenCalls).Count }) 'an unrelated failure is not polled again'

$exchange = & $module {
    $script:TokenCalls.Clear()
    $script:TokenFailures = 2
    $script:TokenError = 'AADSTS7000215: Invalid client secret provided.'
    Invoke-EntraTokenExchange -TenantId 't' -ClientId 'c' -ClientSecret 's' -RedirectUri 'http://localhost:8400/' `
        -Code 'code-1' -Scopes @('offline_access') -RetryDelaySeconds 0
}
Assert-Equal 'refresh-value' $exchange.refresh_token 'the exchange survives a secret that activates late'
$attempts = & $module { @($script:TokenCalls) }
Assert-Equal 3 $attempts.Count 'the exchange retries a replication failure'
Assert-Equal 'code-1' $attempts[-1].code 'the retry reuses the same authorization code'

$exchangeError = $null
try {
    & $module {
        $script:TokenCalls.Clear()
        $script:TokenFailures = 9
        $script:TokenError = 'AADSTS70008: the authorization code expired'
        Invoke-EntraTokenExchange -TenantId 't' -ClientId 'c' -ClientSecret 's' -RedirectUri 'http://localhost:8400/' `
            -Code 'code-1' -Scopes @('offline_access') -RetryDelaySeconds 0
    }
}
catch { $exchangeError = $_.Exception.Message }
Assert-True ($exchangeError -like '*AADSTS70008*') 'an unrelated exchange failure is reported verbatim'
Assert-Equal 1 (& $module { @($script:TokenCalls).Count }) 'a spent authorization code is not retried'

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