tests/Test-IWCheck.ps1

#Requires -Version 7
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot '_TestHelpers.ps1')

$wizardRoot = Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $wizardRoot 'modules/IW.Check.psm1') -Force
Import-Module (Join-Path $wizardRoot 'modules/IW.Core.psm1') -Force
Import-Module (Join-Path $wizardRoot 'modules/IW.Render.psm1') -Force
Import-Module (Join-Path $wizardRoot 'modules/ISC.OperatorConsole.psm1') -Force -Global -WarningAction SilentlyContinue

$script:AssertionCount = 0

$readOnlyLines = Get-CheckLinesForAccess -Lists @{ ReadOnly = @('iam:ListUsers'); ReadWrite = @('iam:CreateUser') } -Access 'ReadOnly'
Assert-Equal 'iam:ListUsers' (@($readOnlyLines.Required) -join ',') 'ReadOnly check requires the read actions'
Assert-Equal 'iam:CreateUser' (@($readOnlyLines.PlanningOnly) -join ',') 'ReadOnly check keeps write actions out of the requirement'

$readWriteLines = Get-CheckLinesForAccess -Lists @{ ReadOnly = @('iam:ListUsers'); ReadWrite = @('iam:CreateUser') } -Access 'ReadWrite'
Assert-Equal 'iam:CreateUser,iam:ListUsers' ((@($readWriteLines.Required) | Sort-Object) -join ',') 'ReadWrite check requires both'
Assert-Equal 0 @($readWriteLines.PlanningOnly).Count 'ReadWrite has no planning-only list'

$star = Compare-IntegrationRequirementCoverage -Required @('iam:ListUsers', 'iam:GetUser') -Granted @('iam:*') -ReadableFamilies @('Aws')
Assert-Equal 2 @($star.Met).Count 'iam:* covers both list and get'
Assert-Equal 0 @($star.Missing).Count 'iam:* leaves nothing missing'

$prefix = Compare-IntegrationRequirementCoverage -Required @('iam:GetUser') -Granted @('iam:List*') -ReadableFamilies @('Aws')
Assert-Equal 1 @($prefix.Missing).Count 'iam:List* does not cover iam:GetUser'

$agent = Compare-IntegrationRequirementCoverage -Required @('bedrock-agentcore:ListAgentRuntimes') -Granted @('bedrock-agentcore:*') -ReadableFamilies @('Aws')
Assert-Equal 1 @($agent.Met).Count 'hyphenated service actions are AWS actions and honor wildcards'

$denied = Compare-IntegrationRequirementCoverage -Required @('iam:ListUsers') -Granted @('iam:*') -Denied @('iam:ListUsers') -ReadableFamilies @('Aws')
Assert-Equal 0 @($denied.Met).Count 'an explicit deny is not treated as present'
Assert-True ((@($denied.Missing) -join ' ') -match 'denied') 'deny is called out in the gap'

$graph = Compare-IntegrationRequirementCoverage -Required @('Microsoft Graph: User.Read.All') -Granted @('User.Read.All') -ReadableFamilies @('Named')
Assert-Equal 1 @($graph.Met).Count 'a granted Graph permission matches the resource-qualified requirement'

$scopes = Compare-IntegrationRequirementCoverage -Required @('https://www.googleapis.com/auth/admin.directory.user.readonly', 'logging.buckets.get') -Granted @('logging.buckets.get') -ReadableFamilies @('Iam')
Assert-Equal 'logging.buckets.get' (@($scopes.Met) -join ',') 'GCP IAM permissions compare exactly'
Assert-Equal 1 @($scopes.Manual).Count 'OAuth scopes are left for a person when the reader cannot see them'
Assert-Equal 0 @($scopes.Missing).Count 'an unreadable scope is not reported as missing'

$encoded = [uri]::EscapeDataString('{"Statement":[{"Effect":"Allow","Action":"s3:ListBucket"}]}')
$decoded = Get-IamPolicyGrantSet -Document $encoded
Assert-Equal 's3:ListBucket' (@($decoded.Allow) -join ',') 'URL-encoded policy documents are read'

$mixed = Get-IamPolicyGrantSet -Document @{
    Statement = @(
        @{ Effect = 'Allow'; Action = 'iam:ListUsers' }
        @{ Effect = 'Deny'; Action = @('iam:CreateUser'); NotAction = 'iam:DeleteUser' }
    )
}
Assert-Equal 'iam:ListUsers' (@($mixed.Allow) -join ',') 'allow actions are collected'
Assert-Equal 'iam:CreateUser' (@($mixed.Deny) -join ',') 'deny actions are collected separately'
Assert-True $mixed.NotAction 'NotAction is flagged instead of being expanded'

$saas = Get-IntegrationCheckSubject -Definition @{ Id = 'aws-saas-connector'; Title = 'AWS' } -Context @{}
Assert-Equal 'SailPointAWSRole' $saas.Name 'AWS SaaS check defaults to the connector role name'
Assert-True $saas.Readable 'AWS SaaS role can be read'

$databricks = Get-IntegrationCheckSubject -Definition @{ Id = 'databricks-connector'; Title = 'Databricks' } -Context @{
    ServicePrincipalName = 'sp-1'; AccountId = 'acc'
}
Assert-Equal 'Manual' $databricks.Kind 'Databricks account admin is not read from the CLI'
Assert-True ($databricks.Note -match 'Account Admin') 'Databricks note names the requirement'
Assert-True ($databricks.Note -match 'does not call Databricks') 'Databricks note says the tenant is not called'

Assert-Equal 'Pass' (Get-IntegrationCheckVerdict -AttemptedRead $true -Found $true -Incomplete $false -RequiredCount 2 -MissingCount 0 -ManualCount 0) 'complete coverage passes'
Assert-Equal 'Fail' (Get-IntegrationCheckVerdict -AttemptedRead $true -Found $false -Incomplete $false -RequiredCount 3 -MissingCount 0 -ManualCount 0) 'a missing identity fails'
Assert-Equal 'Review' (Get-IntegrationCheckVerdict -AttemptedRead $false -Found $false -Incomplete $false -RequiredCount 3 -MissingCount 0 -ManualCount 3) 'skipping the read asks a person to compare'
Assert-Equal 'Review' (Get-IntegrationCheckVerdict -AttemptedRead $true -Found $true -Incomplete $false -RequiredCount 0 -MissingCount 0 -ManualCount 0) 'an empty requirement list is not a pass'

function Set-CheckInvoker {
    param([scriptblock]$Invoker)
    $script:CheckCalls = @()
    Set-IntegrationCheckInvoker -Invoker $Invoker
}

Set-CheckInvoker {
    param($Command, $Arguments)
    $line = (@($Command) + @($Arguments)) -join ' '
    $script:CheckCalls += $line
    if ($line -match 'get-policy-version') {
        return @{ ExitCode = 0; Output = '{"PolicyVersion":{"Document":{"Statement":[{"Effect":"Allow","Action":["iam:ListUsers","iam:GetUser"],"Resource":"*"}]}}}' }
    }
    if ($line -match 'get-policy ') {
        return @{ ExitCode = 0; Output = '{"Policy":{"DefaultVersionId":"v3","Arn":"arn:aws:iam::1:policy/SPAggregationPolicy"}}' }
    }
    if ($line -match 'list-attached-role-policies') {
        return @{ ExitCode = 0; Output = '{"AttachedPolicies":[{"PolicyName":"SPAggregationPolicy","PolicyArn":"arn:aws:iam::1:policy/SPAggregationPolicy"}]}' }
    }
    if ($line -match 'list-role-policies') {
        return @{ ExitCode = 0; Output = '{"PolicyNames":[]}' }
    }
    if ($line -match 'get-role') {
        return @{ ExitCode = 0; Output = '{"Role":{"RoleName":"SailPointAWSRole"}}' }
    }
    return @{ ExitCode = 1; Output = "unexpected $line" }
}
$role = Read-AwsIamRoleGrants -RoleName 'SailPointAWSRole' -Context @{}
Assert-True $role.Found 'existing IAM role is found'
Assert-Equal 'iam:GetUser,iam:ListUsers' ((@($role.Grants) | Sort-Object) -join ',') 'attached policy actions are the grants'
Assert-Equal 0 @($script:CheckCalls | Where-Object { $_ -match 'create-role|attach-role-policy|put-role-policy' }).Count 'reading a role does not call a write API'

Set-CheckInvoker {
    param($Command, $Arguments)
    return @{ ExitCode = 254; Output = 'An error occurred (NoSuchEntity) when calling the GetRole operation: The role with name Missing was not found' }
}
$missingRole = Read-AwsIamRoleGrants -RoleName 'Missing' -Context @{}
Assert-True (-not $missingRole.Found) 'unknown IAM role is not found'
Assert-True ($missingRole.Note -match 'Nothing was changed') 'unknown role note says nothing was changed'

Set-CheckInvoker {
    param($Command, $Arguments)
    $line = (@($Command) + @($Arguments)) -join ' '
    if ($line -match 'ad app list') {
        return @{ ExitCode = 0; Output = '[{"appId":"app-1","displayName":"SailPoint-ISC"}]' }
    }
    if ($line -match 'ad sp list') {
        return @{ ExitCode = 0; Output = '[{"id":"sp-1","appId":"app-1"}]' }
    }
    if ($line -match 'appRoleAssignments') {
        return @{ ExitCode = 0; Output = '{"value":[{"appRoleId":"role-1","resourceId":"graph-sp"}]}' }
    }
    if ($line -match 'graph-sp') {
        return @{ ExitCode = 0; Output = '{"displayName":"Microsoft Graph","appRoles":[{"id":"role-1","value":"User.Read.All"}]}' }
    }
    return @{ ExitCode = 1; Output = "unexpected $line" }
}
$app = Read-EntraApplicationGrants -DisplayName 'SailPoint-ISC'
Assert-True $app.Found 'existing app registration is found'
Assert-Equal 'User.Read.All' (@($app.Grants) -join ',') 'granted app role is resolved to its permission name'

Set-CheckInvoker {
    param($Command, $Arguments)
    return @{ ExitCode = 0; Output = '[]' }
}
$missingApp = Read-EntraApplicationGrants -DisplayName 'Not-There'
Assert-True (-not $missingApp.Found) 'unknown app registration is not found'
Assert-True ($missingApp.Note -match 'Nothing was changed') 'unknown app note says nothing was changed'

Set-CheckInvoker {
    param($Command, $Arguments)
    $line = (@($Command) + @($Arguments)) -join ' '
    if ($line -match 'service-accounts describe') { return @{ ExitCode = 0; Output = '{"email":"sa@proj.iam.gserviceaccount.com"}' } }
    if ($line -match 'projects get-iam-policy') {
        return @{ ExitCode = 0; Output = '{"bindings":[{"role":"roles/logging.viewer","members":["serviceAccount:sa@proj.iam.gserviceaccount.com"]}]}' }
    }
    if ($line -match 'organizations get-iam-policy') { return @{ ExitCode = 0; Output = '{"bindings":[]}' } }
    if ($line -match 'roles describe') { return @{ ExitCode = 0; Output = '{"includedPermissions":["logging.buckets.get","logging.logs.list"]}' } }
    return @{ ExitCode = 1; Output = "unexpected $line" }
}
$sa = Read-GcpServiceAccountGrants -Email 'sa@proj.iam.gserviceaccount.com' -ProjectId 'proj' -OrganizationId '123'
Assert-True $sa.Found 'existing service account is found'
Assert-Equal 'logging.buckets.get,logging.logs.list' ((@($sa.Grants) | Sort-Object) -join ',') 'bound role permissions are the grants'

$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("iw-check-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tmp | Out-Null

Set-IntegrationCheckInvoker -Invoker { param($Command, $Arguments) $script:CheckCalled = $true; return @{ ExitCode = 1; Output = 'should not be called' } }
$script:CheckCalled = $false
$noSession = Invoke-IntegrationRequirementCheck -Definition @{ Id = 'aws-saas-connector'; Title = 'Amazon Web Services SaaS connector IAM role' } -Context @{
    Access = 'ReadOnly'; RoleName = 'SailPointAWSRole'; PolicySet = 'Mgo'; ConnectionMode = 'NoSession'
} -OutputPath (Join-Path $tmp 'no-session-check.md')
Assert-Equal 'Review' $noSession.Verdict 'Check without a session does not pretend to have read the role'
Assert-True (-not $script:CheckCalled) 'Check without a session makes no CLI call'
$noSessionText = Get-Content (Join-Path $tmp 'no-session-check.md') -Raw
Assert-True ($noSessionText -match 'Nothing in the tenant was created or changed') 'check report states that the tenant was not changed'
Assert-True ($noSessionText -match 'No CLI session') 'check report explains that the live identity was not read'

$script:CheckCalled = $false
$whatIf = Invoke-IntegrationRequirementCheck -Definition @{ Id = 'aws-saas-connector'; Title = 'Amazon Web Services SaaS connector IAM role' } -Context @{
    Access = 'ReadOnly'; RoleName = 'SailPointAWSRole'; PolicySet = 'Mgo'
} -OutputPath (Join-Path $tmp 'whatif-check.md') -WhatIf
Assert-Equal 'Review' $whatIf.Verdict 'WhatIf Check does not claim a result'
Assert-True (-not $script:CheckCalled) 'WhatIf Check makes no CLI call'

Set-CheckInvoker {
    param($Command, $Arguments)
    $line = (@($Command) + @($Arguments)) -join ' '
    $script:CheckCalls += $line
    if ($line -match 'list-attached-role-policies') { return @{ ExitCode = 0; Output = '{"AttachedPolicies":[]}' } }
    if ($line -match 'list-role-policies') { return @{ ExitCode = 0; Output = '{"PolicyNames":["inline"]}' } }
    if ($line -match 'get-role-policy') {
        return @{ ExitCode = 0; Output = '{"PolicyDocument":{"Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}}' }
    }
    if ($line -match 'get-role') { return @{ ExitCode = 0; Output = '{"Role":{"RoleName":"SailPointAWSRole"}}' } }
    return @{ ExitCode = 1; Output = "unexpected $line" }
}
$pass = Invoke-IntegrationRequirementCheck -Definition @{ Id = 'aws-saas-connector'; Title = 'Amazon Web Services SaaS connector IAM role' } -Context @{
    Access = 'ReadWrite'; RoleName = 'SailPointAWSRole'; PolicySet = 'Mgo'
} -OutputPath (Join-Path $tmp 'pass-check.md')
Assert-Equal 'Pass' $pass.Verdict 'a role whose policy is * meets the AWS SaaS requirements'
Assert-Equal 0 @($script:CheckCalls | Where-Object { $_ -match 'create-|attach-|put-|delete-' }).Count 'a passing check does not call a write API'
$passText = Get-Content (Join-Path $tmp 'pass-check.md') -Raw
Assert-True ($passText -match 'Meets the requirements') 'pass report says the identity meets the requirements'

Set-CheckInvoker {
    param($Command, $Arguments)
    $line = (@($Command) + @($Arguments)) -join ' '
    if ($line -match 'list-attached-role-policies|list-role-policies') { return @{ ExitCode = 0; Output = '{"AttachedPolicies":[],"PolicyNames":[]}' } }
    if ($line -match 'get-role') { return @{ ExitCode = 0; Output = '{"Role":{"RoleName":"SailPointAWSRole"}}' } }
    return @{ ExitCode = 1; Output = "unexpected $line" }
}
$failed = $false
try {
    Invoke-IntegrationRequirementCheck -Definition @{ Id = 'aws-saas-connector'; Title = 'Amazon Web Services SaaS connector IAM role' } -Context @{
        Access = 'ReadOnly'; RoleName = 'SailPointAWSRole'; PolicySet = 'Mgo'; NonInteractive = $true
    } -OutputPath (Join-Path $tmp 'fail-check.md')
}
catch {
    $failed = $true
    Assert-True ($_.Exception.Message -match 'Nothing was changed') 'non-interactive failure says nothing was changed'
}
Assert-True $failed 'non-interactive Check fails the command when the role does not meet the requirements'
Assert-True (Test-Path (Join-Path $tmp 'fail-check.md')) 'the report is written before the command fails'
$failText = Get-Content (Join-Path $tmp 'fail-check.md') -Raw
Assert-True ($failText -match 'Does not meet the requirements') 'failure report names the verdict'

$fixture = @{
    Id = 'check-fixture'; Title = 'Fixture'; Platform = 'Test'; Product = 'ISC'; CliName = 'aws'
    Inputs = @(); Steps = { param($ctx) @() }
    BuildScript = { param($c) 'Write-Host fixture' }
}
New-IntegrationWizardRequirementsDoc -Definition $fixture -Context @{ Action = 'BuildRequirements' } -OutputPath (Join-Path $tmp 'fixture-requirements.md')
$reqText = Get-Content (Join-Path $tmp 'fixture-requirements.md') -Raw
Assert-True ($reqText -match 'did not change the tenant') 'requirements checklist says generating it changed nothing'

New-IntegrationWizardBuildScript -Definition $fixture -Context @{ Action = 'BuildScript' } -OutputPath (Join-Path $tmp 'fixture.ps1')
$scriptText = Get-Content (Join-Path $tmp 'fixture.ps1') -Raw
Assert-True ($scriptText -match 'only wrote this file') 'generated script says the module only wrote the file'
Assert-True ($scriptText -match 'Running it changes the tenant') 'generated script says running it is the change'

function global:Read-Choice {
    param($Prompt, $Options, $Labels, $Default)
    if ($Default) { return $Default }
    return @($Options)[0]
}
$actionCtx = @{}
& (Get-Module IW.Core) { Resolve-IntegrationWizardAction -Context $args[0] -Wizard } $actionCtx
Assert-Equal 'BuildRequirements' $actionCtx['Action'] 'the wizard defaults to the checklist, not Run'

Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
Set-IntegrationCheckInvoker -Invoker $null

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