tests/Test-GoogleEntroRun.ps1

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

$wizardRoot = Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $wizardRoot 'modules/IW.Core.psm1') -Force
Import-Module (Join-Path $wizardRoot 'modules/Definitions/Google.Entro.psm1') -Force
Register-GoogleEntroDefinitions

$def = Get-IntegrationWizardDefinition -Id 'google-gcp-entro'
Assert-True ($null -ne $def) 'GCP Entro definition is registered'

$ctx = @{
    OrganizationId     = '416188549418'
    ProjectId          = 'tes-sea-trial'
    Method             = 'PrivateKey'
    ServiceAccountName = 'entro-automatic-onboard'
    OutputDirectory    = "/tmp/o'brien"
}
$steps = @(& $def.Steps $ctx)
Assert-Equal 7 @($steps).Count 'PrivateKey run has precheck, APIs, service account, IAM, key, Entro upload, and audit'
Assert-Equal 'precheck,apis,sa-create,iam,key,entro-upload,audit' (($steps | ForEach-Object { $_.Id }) -join ',') 'PrivateKey steps run in onboarding order'

function Get-EntroStep {
    param($All, [string]$Id)
    @($All) | Where-Object { $_.Id -eq $Id } | Select-Object -First 1
}

$pre = Get-EntroStep $steps 'precheck'
Assert-Equal 'Ensure' $pre.Kind 'precheck is executed'
$preScript = & $pre.BuildScript $ctx
Assert-True ($preScript -match 'Invoke-EntroGcpPreOnboardingCheck') 'precheck script runs the Entro check'
Assert-True ($preScript.Contains("'/tmp/o''brien/entro-gcp-report.md'")) 'report path is the markdown file'
Assert-True ($preScript -notmatch 'Mode apply') 'precheck does not apply changes'

$apiScript = & (Get-EntroStep $steps 'apis').BuildScript $ctx
$applyAt = $apiScript.IndexOf('Mode apply')
$verifyAt = $apiScript.IndexOf('Mode verify')
Assert-True ($applyAt -ge 0 -and $applyAt -lt $verifyAt) 'API step applies, then verifies'
Assert-True ($apiScript -match '416188549418') 'API step receives the organization id'
Assert-True ($apiScript -match 'tes-sea-trial') 'API step receives the host project'

$sa = Get-EntroStep $steps 'sa-create'
Assert-True ($sa.ContainsKey('Probe')) 'service account step checks for an existing account'
$saScript = & $sa.BuildScript $ctx
Assert-True ($saScript -match 'Invoke-EntroGcpEnsureServiceAccount') 'service account step creates the reader account'
Assert-True ($saScript -match 'entro-automatic-onboard') 'service account step uses the requested name'

$iam = Get-EntroStep $steps 'iam'
Assert-True ($iam.ContainsKey('Probe')) 'IAM step checks whether the grants are already in place'
$iamScript = & $iam.BuildScript $ctx
Assert-True ($iamScript -match 'Mode apply') 'IAM script applies the grants'
Assert-True ($iamScript -match 'StateDirectory') 'IAM script keeps state in the integration folder'
Assert-True ($iamScript.Contains("-StateDirectory '/tmp/o''brien'")) 'IAM state directory is single-quoted'

$key = Get-EntroStep $steps 'key'
Assert-Equal 'Ensure' $key.Kind 'private key is created with the signed-in gcloud session'
Assert-True ($key.ContainsKey('Run')) 'the wizard creates the key'
$keyScript = & $key.BuildScript $ctx
Assert-True ($keyScript -match 'Invoke-EntroGcpEnsureServiceAccountKey') 'key script creates the JSON key'
Assert-True ($keyScript.Contains("'/tmp/o''brien/entro-reader-key.json'")) 'key script writes into the integration folder'
Assert-True ($keyScript -notmatch 'Get-Content') 'key script does not read the key back'
Assert-True ($keyScript -notmatch 'private_key') 'key script does not embed key material'

$upload = Get-EntroStep $steps 'entro-upload'
Assert-Equal 'Manual' $upload.Kind 'uploading the key into Entro stays an operator action'
Assert-True ($upload.Manual -match 'Entro GCP onboarding') 'the upload step names Entro onboarding'
Assert-True ($upload.Manual -match [regex]::Escape("/tmp/o'brien/entro-reader-key.json")) 'the upload step points at the key file'
Assert-True ($upload.Manual -notmatch 'keys create') 'the upload step is not the key command'

$audit = Get-EntroStep $steps 'audit'
Assert-Equal 'Ensure' $audit.Kind 'audit logs are set with gcloud'
Assert-True ($audit.ContainsKey('Run')) 'the audit step runs the org IAM change'
$auditScript = & $audit.BuildScript $ctx
Assert-True ($auditScript -match 'Invoke-EntroGcpAuditConfigs') 'audit script sets the organization audit configs'
Assert-True ($auditScript -match '416188549418') 'audit script targets the organization'
Assert-True ((Get-EntroStep $steps 'entro-upload').ContainsKey('When')) 'the upload step only applies when the run has a key'

$defaultCtx = @{
    OrganizationId  = '416188549418'
    ProjectId       = 'tes-sea-trial'
    Method          = 'PrivateKey'
    OutputDirectory = '/tmp/entro-out'
}
$defaultKey = Get-EntroStep @(& $def.Steps $defaultCtx) 'key'
$defaultKeyScript = & $defaultKey.BuildScript $defaultCtx
Assert-True ($defaultKeyScript -match 'entro-automatic-onboard') 'omitted service account name uses the Entro default'

$wifCtx = @{
    OrganizationId     = '416188549418'
    ProjectId          = 'tes-sea-trial'
    Method             = 'WorkloadIdentity'
    ServiceAccountName = 'entro-automatic-onboard'
    OutputDirectory    = '/tmp/entro-out'
}
$wifSteps = @(& $def.Steps $wifCtx)
Assert-Equal 'precheck,apis,sa-create,iam,wif,audit' (($wifSteps | ForEach-Object { $_.Id }) -join ',') 'Workload Identity skips private key creation'
$wif = Get-EntroStep $wifSteps 'wif'
Assert-Equal 'Manual' $wif.Kind 'Workload Identity handoff is an operator action'
Assert-True ($wif.Manual -match 'workload identity pool') 'Workload Identity run tells the operator what is left'
Assert-True ($wif.Manual -notmatch 'keys create') 'Workload Identity run does not create a private key'

Import-Module (Join-Path $wizardRoot 'modules/ISC.OperatorConsole.psm1') -Force -Global -WarningAction SilentlyContinue
Import-Module (Join-Path $wizardRoot 'modules/ISC.EntroGcp.psm1') -Force
Initialize-OperatorConsole -NonInteractive
$keyDir = Join-Path ([System.IO.Path]::GetTempPath()) ("entro-key-{0}" -f [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $keyDir | Out-Null
$keyFile = Join-Path $keyDir 'entro-reader-key.json'
$script:KeyCreates = 0
function global:Invoke-GCloud {
    param([string[]]$GcloudArgs, [switch]$ExpectJson)
    $line = $GcloudArgs -join ' '
    if ($line -match 'keys create') {
        $script:KeyCreates++
        $path = $GcloudArgs[4]
        @{
            type          = 'service_account'
            client_email  = 'entro-automatic-onboard@tes-sea-trial.iam.gserviceaccount.com'
            private_key   = "-----BEGIN PRIVATE KEY-----`nnot-a-real-key`n-----END PRIVATE KEY-----`n"
        } | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding utf8
        return 'created key'
    }
    throw "unexpected gcloud call: $line"
}
try {
    $keyCtx = @{}
    Invoke-EntroGcpEnsureServiceAccountKey -ProjectId 'tes-sea-trial' -ServiceAccountName 'entro-automatic-onboard' -KeyPath $keyFile -Context $keyCtx
    $created = Get-EntroGcpRunRecord -Context $keyCtx
    Assert-Equal 'Created in this run' $created.KeyResult 'a missing key file is created'
    Assert-Equal $keyFile $created.KeyPath 'the report records the key path'
    Assert-Equal 1 $script:KeyCreates 'gcloud keys create runs once'
    $again = @{}
    Invoke-EntroGcpEnsureServiceAccountKey -ProjectId 'tes-sea-trial' -ServiceAccountName 'entro-automatic-onboard' -KeyPath $keyFile -Context $again
    Assert-Equal 'Already on disk' (Get-EntroGcpRunRecord -Context $again).KeyResult 'an existing key file is reused'
    Assert-Equal 1 $script:KeyCreates 'a second run does not mint another key'
    $blockedDir = Join-Path ([System.IO.Path]::GetTempPath()) ("entro-key-blocked-{0}" -f [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $blockedDir | Out-Null
    $blockedFile = Join-Path $blockedDir 'entro-reader-key.json'
    function global:Invoke-GCloud {
        param([string[]]$GcloudArgs, [switch]$ExpectJson)
        if (($GcloudArgs -join ' ') -match 'keys create') {
            Set-Content -LiteralPath $GcloudArgs[4] -Value '' -Encoding utf8
            throw 'gcloud iam service-accounts keys create failed: constraints/iam.managed.disableServiceAccountKeyCreation'
        }
        throw 'unexpected gcloud call'
    }
    $blocked = @{}
    $null = Invoke-EntroGcpEnsureServiceAccountKey -ProjectId 'tes-sea-trial' -ServiceAccountName 'entro-automatic-onboard' -KeyPath $blockedFile -Context $blocked
    $blockedRecord = Get-EntroGcpRunRecord -Context $blocked
    Assert-Equal 'Blocked by organization policy' $blockedRecord.KeyResult 'an org policy refusal is recorded'
    Assert-True (-not (Test-Path -LiteralPath $blockedFile)) 'a failed key create does not leave an empty file'
    Assert-True ($blockedRecord.KeyGuidance -match 'roles/orgpolicy.policyAdmin') 'the refusal names who can change the policy'
    Assert-True ($blockedRecord.KeyGuidance -match 'enforce: false') 'the refusal shows the project override'
    Assert-True ($blockedRecord.KeyGuidance -match 'projects/tes-sea-trial/policies/iam.managed.disableServiceAccountKeyCreation') 'the override names this project and this constraint'
    Assert-True ($blockedRecord.KeyGuidance -match '15 minutes') 'the refusal says to wait for the policy to take effect'
    $other = Get-EntroGcpPreconditionGuidance -ErrorMessage 'FAILED_PRECONDITION: constraints/compute.vmExternalIpAccess' -ProjectId 'host-proj' -Email 'reader@host-proj.iam.gserviceaccount.com'
    Assert-True ($other -match 'constraints/compute.vmExternalIpAccess') 'another precondition names its constraint'
    Assert-True ($other -match 'roles/orgpolicy.policyAdmin') 'another precondition names the organization policy role'
    Assert-True ($other -notmatch 'enforce: false') 'a constraint the wizard cannot override gets no override file'
    $saGuidance = Get-EntroGcpPreconditionGuidance -ErrorMessage 'FAILED_PRECONDITION: constraints/iam.disableServiceAccountCreation' -ProjectId 'host-proj' -Email 'reader@host-proj.iam.gserviceaccount.com'
    Assert-True ($saGuidance -match 'allow-service-account-creation.yaml') 'service account creation gets its own override file'
    Assert-True ($saGuidance -match 'projects/host-proj/policies/iam.disableServiceAccountCreation') 'the creation override names this project and constraint'
    Assert-True ($saGuidance -notmatch 'WorkloadIdentity') 'the creation override does not suggest the key alternative'
    Assert-True ($null -eq (Get-EntroGcpPreconditionGuidance -ErrorMessage 'PERMISSION_DENIED: not a precondition' -ProjectId 'host-proj' -Email 'reader@host-proj.iam.gserviceaccount.com')) 'a different gcloud error is not treated as a precondition'
    Assert-True ($blockedRecord.KeyGuidance -match 'did not change the policy') 'a non-interactive run does not apply the override'

    $applyDir = Join-Path ([System.IO.Path]::GetTempPath()) ("entro-key-apply-{0}" -f [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $applyDir | Out-Null
    $applyFile = Join-Path $applyDir 'entro-reader-key.json'
    $script:KeyAttempts = 0
    function global:Invoke-GCloud {
        param([string[]]$GcloudArgs, [switch]$ExpectJson)
        $line = $GcloudArgs -join ' '
        if ($line -match 'keys create') {
            $script:KeyAttempts++
            if ($script:KeyAttempts -eq 1) {
                Set-Content -LiteralPath $GcloudArgs[4] -Value '' -Encoding utf8
                throw 'FAILED_PRECONDITION: constraints/iam.managed.disableServiceAccountKeyCreation'
            }
            @{
                type         = 'service_account'
                client_email = 'entro-automatic-onboard@tes-sea-trial.iam.gserviceaccount.com'
                private_key  = "-----BEGIN PRIVATE KEY-----`nnot-a-real-key`n-----END PRIVATE KEY-----`n"
            } | ConvertTo-Json | Set-Content -LiteralPath $GcloudArgs[4] -Encoding utf8
            return 'created key'
        }
        if ($line -match '^services enable orgpolicy.googleapis.com') { return '' }
        if ($line -match '^org-policies set-policy') { return '' }
        throw "unexpected gcloud call: $line"
    }
    $applyCtx = @{ ApplyPreconditionFix = $true; PreconditionWaitSeconds = 0 }
    $null = Invoke-EntroGcpEnsureServiceAccountKey -ProjectId 'tes-sea-trial' -ServiceAccountName 'entro-automatic-onboard' -KeyPath $applyFile -Context $applyCtx
    $applied = Get-EntroGcpRunRecord -Context $applyCtx
    Assert-Equal 'Created in this run' $applied.KeyResult 'yes applies the override and retries the key'
    Assert-Equal 1 @($applied.PolicyOverrides).Count 'the project override is part of the run record'
    Assert-Equal 'iam.managed.disableServiceAccountKeyCreation' $applied.PolicyOverrides[0].Constraint 'the record names the overridden constraint'
    Assert-Equal 2 $script:KeyAttempts 'the key is retried after the override'
    $policyFile = Join-Path $applyDir 'allow-service-account-keys.yaml'
    $policy = Get-Content -LiteralPath $policyFile -Raw
    Assert-True ($policy -match 'projects/tes-sea-trial/policies/iam.managed.disableServiceAccountKeyCreation') 'the policy file names this project'
    Assert-True ($policy -match 'enforce: false') 'the policy file turns the constraint off for this project'
    Remove-Item -LiteralPath $applyDir -Recurse -Force

    # Legacy and managed key constraints enforced together: clearing one exposes the other.
    $bothDir = Join-Path ([System.IO.Path]::GetTempPath()) ("entro-key-both-{0}" -f [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $bothDir | Out-Null
    $bothFile = Join-Path $bothDir 'entro-reader-key.json'
    $script:Overridden = @()
    $script:KeyAttempts = 0
    function global:Invoke-GCloud {
        param([string[]]$GcloudArgs, [switch]$ExpectJson)
        $line = $GcloudArgs -join ' '
        if ($line -match 'keys create') {
            $script:KeyAttempts++
            if ($script:Overridden -notcontains 'iam.disableServiceAccountKeyCreation') { throw 'FAILED_PRECONDITION: constraints/iam.disableServiceAccountKeyCreation' }
            if ($script:Overridden -notcontains 'iam.managed.disableServiceAccountKeyCreation') { throw 'FAILED_PRECONDITION: constraints/iam.managed.disableServiceAccountKeyCreation' }
            @{
                type         = 'service_account'
                client_email = 'entro-automatic-onboard@tes-sea-trial.iam.gserviceaccount.com'
                private_key  = "-----BEGIN PRIVATE KEY-----`nnot-a-real-key`n-----END PRIVATE KEY-----`n"
            } | ConvertTo-Json | Set-Content -LiteralPath $GcloudArgs[4] -Encoding utf8
            return 'created key'
        }
        if ($line -match '^services enable orgpolicy.googleapis.com') { return '' }
        if ($line -match '^org-policies set-policy (.+)$') {
            $name = (Get-Content -LiteralPath $Matches[1] -Raw) -replace '(?s).*policies/([^\s]+).*', '$1'
            $script:Overridden += $name.Trim()
            return ''
        }
        throw "unexpected gcloud call: $line"
    }
    $bothCtx = @{ ApplyPreconditionFix = $true; PreconditionWaitSeconds = 0 }
    $null = Invoke-EntroGcpEnsureServiceAccountKey -ProjectId 'tes-sea-trial' -ServiceAccountName 'entro-automatic-onboard' -KeyPath $bothFile -Context $bothCtx
    $both = Get-EntroGcpRunRecord -Context $bothCtx
    Assert-Equal 'Created in this run' $both.KeyResult 'both key constraints are overridden and the key is created'
    Assert-Equal 2 @($both.PolicyOverrides).Count 'each constraint gets its own override'
    Assert-Equal 2 @(Get-ChildItem -LiteralPath $bothDir -Filter 'allow-*.yaml').Count 'the second override does not overwrite the first file'
    Remove-Item -LiteralPath $bothDir -Recurse -Force

    # The override is applied, and Google keeps refusing until the wait runs out.
    $slowDir = Join-Path ([System.IO.Path]::GetTempPath()) ("entro-key-slow-{0}" -f [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $slowDir | Out-Null
    function global:Invoke-GCloud {
        param([string[]]$GcloudArgs, [switch]$ExpectJson)
        $line = $GcloudArgs -join ' '
        if ($line -match 'keys create') { throw 'FAILED_PRECONDITION: constraints/iam.managed.disableServiceAccountKeyCreation' }
        if ($line -match '^services enable orgpolicy.googleapis.com|^org-policies set-policy') { return '' }
        throw "unexpected gcloud call: $line"
    }
    $slowCtx = @{ ApplyPreconditionFix = $true; PreconditionWaitSeconds = 0 }
    $slowOutcome = Invoke-EntroGcpEnsureServiceAccountKey -ProjectId 'tes-sea-trial' -ServiceAccountName 'entro-automatic-onboard' -KeyPath (Join-Path $slowDir 'entro-reader-key.json') -Context $slowCtx
    $slow = Get-EntroGcpRunRecord -Context $slowCtx
    Assert-Equal 'blocked' $slowOutcome 'a policy that has not taken effect leaves the key blocked'
    Assert-True ($slow.KeyGuidance -match 'still refused the key after waiting') 'the guidance says the override was applied and Google still refused'
    Remove-Item -LiteralPath $slowDir -Recurse -Force

    # Service account creation blocked by organization policy.
    $saDir = Join-Path ([System.IO.Path]::GetTempPath()) ("entro-sa-{0}" -f [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $saDir | Out-Null
    $script:SaCreates = 0
    $script:SaAllowed = $false
    function global:Invoke-GCloud {
        param([string[]]$GcloudArgs, [switch]$ExpectJson)
        $line = $GcloudArgs -join ' '
        if ($line -match '^iam service-accounts describe') { throw 'NOT_FOUND' }
        if ($line -match '^iam service-accounts create') {
            $script:SaCreates++
            if (-not $script:SaAllowed) { throw 'FAILED_PRECONDITION: constraints/iam.disableServiceAccountCreation' }
            return ''
        }
        if ($line -match '^services enable orgpolicy.googleapis.com') { return '' }
        if ($line -match '^org-policies set-policy') { $script:SaAllowed = $true; return '' }
        throw "unexpected gcloud call: $line"
    }
    $saCtx = @{ ApplyPreconditionFix = $true; PreconditionWaitSeconds = 0 }
    Invoke-EntroGcpEnsureServiceAccount -ProjectId 'tes-sea-trial' -ServiceAccountName 'entro-automatic-onboard' -StateDirectory $saDir -Context $saCtx
    Assert-Equal 'Created in this run' (Get-EntroGcpRunRecord -Context $saCtx).ServiceAccountResult 'service account creation is retried after the override'
    Assert-True (Test-Path -LiteralPath (Join-Path $saDir 'allow-service-account-creation.yaml')) 'the creation override is saved in the integration folder'
    $script:SaAllowed = $false
    $saRefused = $null
    try {
        Invoke-EntroGcpEnsureServiceAccount -ProjectId 'tes-sea-trial' -ServiceAccountName 'entro-automatic-onboard' -StateDirectory $saDir -Context @{ ApplyPreconditionFix = $false }
    }
    catch { $saRefused = $_.Exception.Message }
    Assert-True ($saRefused -match 'refused to create') 'declining the override stops the run with a clear reason'
    Remove-Item -LiteralPath $saDir -Recurse -Force

    # A project without billing: Secret Manager and Discovery Engine cannot be enabled there.
    $script:BillingLinked = $false
    $script:BillingAccounts = '[{"name":"billingAccounts/0000AA-BBBBBB-CCCCCC","displayName":"Main","open":true}]'
    function global:Invoke-GCloud {
        param([string[]]$GcloudArgs, [switch]$ExpectJson)
        $line = $GcloudArgs -join ' '
        if ($line -match '^asset search-all-resources .*Folder') { return '' }
        if ($line -match '^asset search-all-resources .*Project') { return '//cloudresourcemanager.googleapis.com/projects/no-billing' }
        if ($line -match '^services list --enabled --project=(\S+) --format') {
            return 'cloudresourcemanager.googleapis.com', 'iam.googleapis.com', 'recommender.googleapis.com', 'policyanalyzer.googleapis.com',
                'pubsub.googleapis.com', 'cloudasset.googleapis.com', 'admin.googleapis.com', 'drive.googleapis.com' -join "`n"
        }
        if ($line -match '^services enable secretmanager') {
            if (-not $script:BillingLinked) { throw "FAILED_PRECONDITION: Billing account for project '123' is not found. Billing must be enabled for activation of service(s) 'secretmanager.googleapis.com' to proceed." }
            return ''
        }
        if ($line -match '^services list --enabled --project=\S+ --filter=config.name=(\S+)') { return $Matches[1] }
        if ($line -match '^billing accounts list') { return $script:BillingAccounts }
        if ($line -match '^billing projects link no-billing --billing-account=0000AA-BBBBBB-CCCCCC') { $script:BillingLinked = $true; return '' }
        throw "unexpected gcloud call: $line"
    }
    $billCtx = @{ ApplyPreconditionFix = $true; PreconditionWaitSeconds = 0 }
    Invoke-EntroGcpEnableApis -OrganizationId '416188549418' -ProjectId 'no-billing' -Mode 'apply' -Context $billCtx
    $billed = Get-EntroGcpRunRecord -Context $billCtx
    Assert-True $script:BillingLinked 'the only open billing account is linked when the operator agrees'
    Assert-Equal 1 @($billed.BillingLinks).Count 'the billing link is part of the run record'
    Assert-Equal 0 @($billed.Blocked).Count 'nothing stays blocked once billing is linked'

    $script:BillingLinked = $false
    $declineCtx = @{ ApplyPreconditionFix = $false; PreconditionWaitSeconds = 0 }
    Invoke-EntroGcpEnableApis -OrganizationId '416188549418' -ProjectId 'no-billing' -Mode 'apply' -Context $declineCtx
    $declined = Get-EntroGcpRunRecord -Context $declineCtx
    Assert-True (-not $script:BillingLinked) 'billing is not linked without consent'
    Assert-Equal 1 @($declined.Blocked).Count 'a project without billing is recorded as blocked instead of stopping the run'
    Assert-Equal 'billing-no-billing' $declined.Blocked[0].Id 'the blocked item is keyed by project'
    Assert-True ($declined.Blocked[0].Guidance -match 'gcloud billing projects link no-billing') 'the guidance shows the link command'
    $blockedApis = @($declined.Apis | Where-Object { $_.Result -like 'Blocked*' } | ForEach-Object { $_.Service })
    Assert-True ($blockedApis -contains 'secretmanager.googleapis.com') 'the refused APIs are marked blocked in the report'
    Invoke-EntroGcpEnableApis -OrganizationId '416188549418' -ProjectId 'no-billing' -Mode 'verify' -Context $declineCtx
    Assert-True $true 'verify skips APIs blocked by billing'
    $report = Format-EntroGcpRunReport -Record $declined
    Assert-True ($report -match 'How to clear: Turn on billing for project no-billing') 'the report explains how to clear the billing block'

    $script:BillingAccounts = '[{"name":"billingAccounts/A"},{"name":"billingAccounts/B"}]'
    $manyCtx = @{ ApplyPreconditionFix = $true; PreconditionWaitSeconds = 0 }
    Invoke-EntroGcpEnableApis -OrganizationId '416188549418' -ProjectId 'no-billing' -Mode 'apply' -Context $manyCtx
    Assert-True (-not $script:BillingLinked) 'with several billing accounts the wizard does not pick one'
    Assert-Equal 1 @((Get-EntroGcpRunRecord -Context $manyCtx).Blocked).Count 'with several billing accounts the project stays blocked'
    Remove-Item -LiteralPath $blockedDir -Recurse -Force
    $mode = (Get-Item -LiteralPath $keyFile).UnixFileMode
    Assert-True (($mode -band [System.IO.UnixFileMode]::GroupRead) -eq 0) 'the key file is not group-readable'
}
finally {
    Remove-Item Function:global:Invoke-GCloud -ErrorAction SilentlyContinue
    if (Test-Path -LiteralPath $keyDir) { Remove-Item -LiteralPath $keyDir -Recurse -Force }
}

$script:PolicySets = @()
$script:PolicyCurrent = @{
    version      = 1
    etag         = 'etag-before'
    bindings     = @(@{ role = 'roles/viewer'; members = @('user:admin@example.com') })
    auditConfigs = @(@{ service = 'iam.googleapis.com'; auditLogConfigs = @(@{ logType = 'DATA_READ'; exemptedMembers = @('user:exempt@example.com') }) })
}
function global:Invoke-GCloud {
    param([string[]]$GcloudArgs, [switch]$ExpectJson)
    $line = $GcloudArgs -join ' '
    if ($line -match '^organizations get-iam-policy') { return ($script:PolicyCurrent | ConvertTo-Json -Depth 20) }
    if ($line -match '^organizations set-iam-policy') {
        $written = Get-Content -LiteralPath $GcloudArgs[3] -Raw | ConvertFrom-Json -AsHashtable -Depth 50
        $script:PolicySets += , $written
        $script:PolicyCurrent = $written
        return ''
    }
    throw "unexpected gcloud call: $line"
}
$auditCtx = @{}
Assert-True (-not (Test-EntroGcpAuditConfigsComplete -OrganizationId '416188549418')) 'a partial audit config is reported as missing'
Invoke-EntroGcpAuditConfigs -OrganizationId '416188549418' -Context $auditCtx
Assert-Equal 1 $script:PolicySets.Count 'audit configs are written once'
$written = $script:PolicySets[0]
Assert-Equal 'etag-before' $written['etag'] 'the write keeps the etag so concurrent changes are rejected'
Assert-Equal 1 @($written['bindings']).Count 'existing org IAM bindings are kept'
Assert-Equal 'roles/viewer' $written['bindings'][0]['role'] 'the existing binding is unchanged'
$iamCfg = @($written['auditConfigs'] | Where-Object { $_['service'] -eq 'iam.googleapis.com' })[0]
Assert-True ((@($iamCfg['auditLogConfigs'] | ForEach-Object { $_['logType'] }) -contains 'DATA_WRITE')) 'the existing IAM config gains DATA_WRITE'
Assert-Equal 'user:exempt@example.com' $iamCfg['auditLogConfigs'][0]['exemptedMembers'][0] 'existing exempted members are kept'
foreach ($svc in @('secretmanager.googleapis.com', 'cloudfunctions.googleapis.com')) {
    $cfg = @($written['auditConfigs'] | Where-Object { $_['service'] -eq $svc })[0]
    Assert-True ($null -ne $cfg) "$svc gets an audit config"
    $types = @($cfg['auditLogConfigs'] | ForEach-Object { $_['logType'] })
    Assert-True (($types -contains 'DATA_READ') -and ($types -contains 'DATA_WRITE')) "$svc logs DATA_READ and DATA_WRITE"
}
Assert-True (Test-EntroGcpAuditConfigsComplete -OrganizationId '416188549418') 'the policy now reports complete'
Assert-Equal 'Applied in this run' (Get-EntroGcpRunRecord -Context $auditCtx).AuditResult 'the run records the audit change'
$rerunCtx = @{}
Invoke-EntroGcpAuditConfigs -OrganizationId '416188549418' -Context $rerunCtx
Assert-Equal 1 $script:PolicySets.Count 'a complete policy is not written again'
Assert-Equal 'Already in place' (Get-EntroGcpRunRecord -Context $rerunCtx).AuditResult 'a rerun records the audit config as already in place'
Remove-Item Function:global:Invoke-GCloud

$upload = Get-EntroStep @(& $def.Steps $ctx) 'entro-upload'
$blockedRun = @{}
(Get-EntroGcpRunRecord -Context $blockedRun).KeyResult = 'Blocked by organization policy'
Assert-True (-not (& $upload.When $blockedRun)) 'the upload step is skipped when Google refused the key'
$keyRun = @{}
(Get-EntroGcpRunRecord -Context $keyRun).KeyResult = 'Created in this run'
Assert-True (& $upload.When $keyRun) 'the upload step is asked when the key exists'

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