tests/Test-AdLdaps.ps1

param(
    [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'modules/ISC.AdLdaps.psm1'),
    [string]$ConsolePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'modules/ISC.OperatorConsole.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'."
    }
}

function Assert-Contains {
    param([string]$Needle, [string]$Haystack, [string]$Message)
    $script:AssertionCount++
    if ($Haystack -notlike "*$Needle*") {
        throw "Assertion failed: $Message. Expected to find '$Needle'."
    }
}

Import-Module $ConsolePath -Force -WarningAction SilentlyContinue
Initialize-OperatorConsole -NonInteractive
Import-Module $ModulePath -Force -WarningAction SilentlyContinue

$serverAuth = '1.3.6.1.5.5.7.3.1'
$digitalSignature = [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature
$keyEncipherment = [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment
$bothKu = $digitalSignature -bor $keyEncipherment

function New-MockCert {
    param(
        [string]$Thumbprint = 'AABBCC',
        [string[]]$DnsNames = @('dc1.contoso.local'),
        [bool]$HasPrivateKey = $true,
        $EnhancedKeyUsageList = @($serverAuth),
        $KeyUsageFlags = $bothKu,
        [string]$KeySpec = 'KeyExchange',
        [datetime]$NotBefore = $(Get-Date).AddDays(-1),
        [datetime]$NotAfter = $(Get-Date).AddYears(1),
        [string]$Subject = 'CN=dc1.contoso.local',
        [string]$FriendlyName = $null,
        [byte[]]$RawData = $null,
        $ChainCertificates = $null
    )

    if (-not $RawData) {
        $RawData = [byte[]](1..80)
    }

    return [PSCustomObject]@{
        Thumbprint            = $Thumbprint
        DnsNames              = $DnsNames
        HasPrivateKey         = $HasPrivateKey
        EnhancedKeyUsageList  = $EnhancedKeyUsageList
        KeyUsageFlags         = $KeyUsageFlags
        KeySpec               = $KeySpec
        NotBefore             = $NotBefore
        NotAfter              = $NotAfter
        Subject               = $Subject
        FriendlyName          = $FriendlyName
        RawData               = $RawData
        ChainCertificates     = $ChainCertificates
        Extensions            = $null
    }
}

# --- PEM encoding ---
$leafBytes = [byte[]](0..69)
$pem = ConvertTo-X509Pem -Certificate ([PSCustomObject]@{ RawData = $leafBytes })
Assert-Contains '-----BEGIN CERTIFICATE-----' $pem 'PEM has begin marker'
Assert-Contains '-----END CERTIFICATE-----' $pem 'PEM has end marker'
$b64Body = (($pem -split "`n") | Where-Object { $_ -notmatch 'CERTIFICATE' }) -join ''
Assert-equal ([Convert]::ToBase64String($leafBytes)) $b64Body 'PEM body is Base64 of RawData'
$lines = @($pem -split "`n" | Where-Object { $_ -notmatch 'CERTIFICATE' -and $_ })
Assert-True ((($lines | Where-Object { $_.Length -gt 64 }).Count) -eq 0) 'PEM lines are at most 64 characters'

# --- Chain export leaf-first ---
$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("adldaps-test-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tempRoot | Out-Null
try {
    $leaf = New-MockCert -Thumbprint 'LEAF01' -RawData ([byte[]](10..19)) -DnsNames @('dc1.contoso.local')
    $intermediate = New-MockCert -Thumbprint 'INT01' -RawData ([byte[]](20..29)) -DnsNames @('ca.contoso.local')
    $root = New-MockCert -Thumbprint 'ROOT01' -RawData ([byte[]](30..39)) -DnsNames @('root.contoso.local')
    $leaf.ChainCertificates = @($leaf, $intermediate, $root)

    $export = Export-AdLdapsCertificateChain -Certificate $leaf -OutputPath $tempRoot -FilePrefix 'dc1.contoso.local-ldaps'
    Assert-equal 3 $export.ChainCount 'chain has leaf + intermediate + root'
    Assert-equal 4 $export.Files.Count 'three member files plus concatenated chain'
    Assert-True (Test-Path -LiteralPath (Join-Path $tempRoot 'dc1.contoso.local-ldaps-00-leaf.pem')) 'leaf file named 00-leaf'
    Assert-True (Test-Path -LiteralPath (Join-Path $tempRoot 'dc1.contoso.local-ldaps-01-intermediate.pem')) 'intermediate file named 01'
    Assert-True (Test-Path -LiteralPath (Join-Path $tempRoot 'dc1.contoso.local-ldaps-02-root.pem')) 'root file named 02-root'
    Assert-True (Test-Path -LiteralPath $export.ChainPath) 'concatenated chain file exists'

    $chainPem = Get-Content -LiteralPath $export.ChainPath -Raw
    $leafPem = Get-Content -LiteralPath (Join-Path $tempRoot 'dc1.contoso.local-ldaps-00-leaf.pem') -Raw
    Assert-True $chainPem.StartsWith($leafPem.Trim()) 'concatenated chain starts with leaf PEM'
}
finally {
    Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
}

# --- VA manual steps ---
$steps = @(Get-AdLdapsVaManualSteps -ChainPath 'C:\SailPoint\va-certificates\dc1-ldaps-chain.pem' -PemDirectory 'C:\SailPoint\va-certificates')
Assert-True ($steps.Count -ge 6) 'VA steps include numbered instructions'
Assert-True ($steps -join ' ' -match '/home/sailpoint/certificates') 'VA steps mention certificates directory'
Assert-True ($steps -join ' ' -match 'systemctl restart ccg') 'VA steps mention ccg restart'
Assert-True ($steps -join ' ' -match 'ccg-start.log') 'VA steps mention ccg-start.log'
Assert-True ($steps -join ' ' -match 'config_va.html') 'VA steps link tenant TLS docs'
Assert-True ($steps -join ' ' -match 'tls_config_on_va.html') 'VA steps link manual upload docs'

# --- Uses gate: accept unrestricted (no EKU / no KU) ---
$unrestricted = New-MockCert -EnhancedKeyUsageList $null -KeyUsageFlags $null -KeySpec 'KeyExchange'
$okUnrestricted = Test-AdLdapsCertificateUses -Certificate $unrestricted
Assert-True $okUnrestricted.Ok 'unrestricted EKU/KU with KeyExchange is accepted'

# --- Uses gate: accept full Schannel shape ---
$full = New-MockCert
$okFull = Test-AdLdapsCertificateUses -Certificate $full
Assert-True $okFull.Ok 'full Server Auth + DigitalSignature + KeyEncipherment + KeyExchange is accepted'

# --- Uses gate: reject missing Server Authentication ---
$clientOnly = New-MockCert -EnhancedKeyUsageList @('1.3.6.1.5.5.7.3.2')
$badEku = Test-AdLdapsCertificateUses -Certificate $clientOnly
Assert-True (-not $badEku.Ok) 'client-auth-only EKU is rejected'
Assert-True ($badEku.Reasons -join ' ' -match 'Server Authentication') 'rejection mentions Server Authentication'

# --- Uses gate: reject missing Key Encipherment ---
$noEncipher = New-MockCert -KeyUsageFlags $digitalSignature
$badKu = Test-AdLdapsCertificateUses -Certificate $noEncipher
Assert-True (-not $badKu.Ok) 'DigitalSignature-only Key Usage is rejected'
Assert-True ($badKu.Reasons -join ' ' -match 'KeyEncipherment') 'rejection mentions KeyEncipherment'

# --- Uses gate: reject signature-only KeySpec ---
$sigOnly = New-MockCert -KeySpec 'Signature'
$badSpec = Test-AdLdapsCertificateUses -Certificate $sigOnly
Assert-True (-not $badSpec.Ok) 'signature-only KeySpec is rejected'
Assert-True ($badSpec.Reasons -join ' ' -match 'signature-only|AT_SIGNATURE|KeyExchange') 'rejection mentions KeySpec'

# --- Uses gate: reject missing private key ---
$noKey = New-MockCert -HasPrivateKey $false
$badKey = Test-AdLdapsCertificateUses -Certificate $noKey
Assert-True (-not $badKey.Ok) 'certificate without private key is rejected'

$fqdn = 'dc1.contoso.local'

# --- Ranking: NTDS preferred, then longest validity ---
$now = Get-Date
$myOld = New-MockCert -Thumbprint 'MYOLD' -NotAfter $now.AddMonths(1) -DnsNames @($fqdn)
$ntdsNew = New-MockCert -Thumbprint 'NTDSNEW' -NotAfter $now.AddYears(2) -DnsNames @($fqdn)
$myLonger = New-MockCert -Thumbprint 'MYLONG' -NotAfter $now.AddYears(3) -DnsNames @($fqdn)

$found = Find-AdLdapsCertificate -DnsName @($fqdn) -RequiredDnsName $fqdn -Candidates @(
    [PSCustomObject]@{ Certificate = $myLonger; StoreName = 'My' }
    [PSCustomObject]@{ Certificate = $ntdsNew; StoreName = 'NTDS' }
    [PSCustomObject]@{ Certificate = $myOld; StoreName = 'My' }
) -Now $now
Assert-equal 'NTDSNEW' $found.Thumbprint 'NTDS store ranks above My even when My has longer validity'

$foundMy = Find-AdLdapsCertificate -DnsName @($fqdn) -RequiredDnsName $fqdn -Candidates @(
    [PSCustomObject]@{ Certificate = $myOld; StoreName = 'My' }
    [PSCustomObject]@{ Certificate = $myLonger; StoreName = 'My' }
) -Now $now
Assert-equal 'MYLONG' $foundMy.Thumbprint 'among My-store certs, longest NotAfter wins'

# --- Thumbprint pin still enforces uses gate ---
$pinnedBad = New-MockCert -Thumbprint 'BADPIN' -KeySpec 'Signature' -DnsNames @($fqdn)
$threw = $false
try {
    Find-AdLdapsCertificate -Thumbprint 'BADPIN' -DnsName @($fqdn) -RequiredDnsName $fqdn -Candidates @(
        [PSCustomObject]@{ Certificate = $pinnedBad; StoreName = 'My' }
    ) -Now $now | Out-Null
}
catch {
    $threw = $true
    Assert-Contains 'required LDAPS uses' $_.Exception.Message 'pinned unusable cert fails with uses message'
}
Assert-True $threw 'pinned signature-only cert throws'

# --- Name mismatch rejected from accepted set ---
$wrongHost = New-MockCert -Thumbprint 'WRONG' -DnsNames @('other.contoso.local')
$foundNone = Find-AdLdapsCertificate -DnsName @($fqdn) -RequiredDnsName $fqdn -Candidates @(
    [PSCustomObject]@{ Certificate = $wrongHost; StoreName = 'My' }
) -Now $now
Assert-True ($null -eq $foundNone.Certificate) 'name mismatch yields no accepted certificate'
Assert-equal 1 $foundNone.Rejected.Count 'name mismatch is recorded as rejected'

# --- Short name alone is not enough (AD DS needs FQDN) ---
$shortOnly = New-MockCert -Thumbprint 'SHORT' -DnsNames @('dc1')
$shortCheck = Test-AdLdapsCertificateCandidate -Certificate $shortOnly -HostNames @($fqdn, 'dc1') -RequiredDnsName $fqdn -Now $now
Assert-True (-not $shortCheck.Ok) 'short-name-only cert fails candidate gate'
Assert-True ($shortCheck.Reasons -join ' ' -match 'FQDN|short name') 'short-name rejection mentions FQDN'

$foundShort = Find-AdLdapsCertificate -DnsName @($fqdn) -RequiredDnsName $fqdn -Candidates @(
    [PSCustomObject]@{ Certificate = $shortOnly; StoreName = 'My' }
) -Now $now
Assert-True ($null -eq $foundShort.Certificate) 'short-name-only cert is not selectable'

# --- Choice labels ---
$labelEntry = [PSCustomObject]@{
    StoreName  = 'NTDS'
    DnsNames   = @('dc1.contoso.local', 'dc1', 'extra.contoso.local')
    NotAfter   = [datetime]'2027-06-15'
    Thumbprint = 'AABBCCDDEEFF00112233445566778899AABBCCDD'
}
$label = Format-AdLdapsCertificateChoiceLabel -Entry $labelEntry -IncludeThumbprint
Assert-Contains 'NTDS' $label 'choice label includes store'
Assert-Contains 'dc1.contoso.local' $label 'choice label includes primary DNS'
Assert-Contains '…' $label 'choice label truncates extra DNS and long thumbprint'
Assert-Contains 'expires 2027-06-15' $label 'choice label includes expiry'

# --- Interactive selection defaults (NonInteractive uses Default) ---
$pickExisting = Select-AdLdapsCertificate -DnsName @($fqdn) -RequiredDnsName $fqdn -Candidates @(
    [PSCustomObject]@{ Certificate = $ntdsNew; StoreName = 'NTDS' }
    [PSCustomObject]@{ Certificate = $myLonger; StoreName = 'My' }
) -Now $now
Assert-equal 'NTDSNEW' $pickExisting.Thumbprint 'selection defaults to top-ranked usable cert'
Assert-True (-not $pickExisting.CreateSelfSigned) 'selection of existing cert is not CreateSelfSigned'

$pickCreate = Select-AdLdapsCertificate -DnsName @($fqdn) -RequiredDnsName $fqdn -Candidates @(
    [PSCustomObject]@{ Certificate = $wrongHost; StoreName = 'My' }
) -Now $now
Assert-True $pickCreate.CreateSelfSigned 'when nothing is usable, selection defaults to create'
Assert-True ($null -eq $pickCreate.Thumbprint) 'create selection has no thumbprint'

# --- Schannel ready: FQDN required; untrusted-root soft for self-signed path is separate ---
$schannelShort = Test-AdLdapsCertificateSchannelReady -Certificate $shortOnly -DnsName $fqdn
Assert-True (-not $schannelShort.Ok) 'Schannel ready rejects short-name-only cert'
Assert-True ($schannelShort.Reasons -join ' ' -match 'FQDN|Short name') 'Schannel ready message mentions FQDN'

# --- NTDS service store registry paths ---
$ntdsRoot = Get-AdLdapsNtdsServiceCertificateRegistryPath
Assert-Contains 'Cryptography\Services\NTDS\SystemCertificates\My\Certificates' $ntdsRoot 'NTDS service store path uses Cryptography\\Services\\NTDS'
$myReg = Get-AdLdapsLocalMachineCertificateRegistryPath -Thumbprint 'aa bb cc'
Assert-Contains 'SystemCertificates\My\Certificates\AABBCC' $myReg 'LocalMachine My registry path normalizes thumbprint'

# --- EKU list may stringify to friendly names; still detect Server Authentication ---
$friendlyEku = New-MockCert -EnhancedKeyUsageList @('Server Authentication')
$okFriendly = Test-AdLdapsCertificateUses -Certificate $friendlyEku
Assert-True $okFriendly.Ok 'Server Authentication friendly name is accepted as Server Auth EKU'

# --- Managed SailPoint LDAPS cert detection + labels ---
$managed = New-MockCert -Thumbprint 'MANAGED1' -FriendlyName 'SailPoint AD LDAPS (dc1.contoso.local)' -DnsNames @($fqdn)
Assert-True (Test-AdLdapsManagedCertificate -Certificate $managed) 'SailPoint AD LDAPS friendly name is managed'
$other = New-MockCert -Thumbprint 'OTHER1' -FriendlyName 'File Access Manager RabbitMQ' -DnsNames @($fqdn)
Assert-True (-not (Test-AdLdapsManagedCertificate -Certificate $other)) 'RabbitMQ friendly name is not managed'
$managedLabel = Format-AdLdapsCertificateChoiceLabel -Entry ([PSCustomObject]@{
    StoreName   = 'My'
    Certificate = $managed
    DnsNames    = @($fqdn)
    NotAfter    = $now.AddYears(1)
    Thumbprint  = 'MANAGED1'
}) -IncludeThumbprint
Assert-Contains 'SailPoint AD LDAPS' $managedLabel 'choice label surfaces managed friendly name'

# --- Unusable managed cert fails uses (picker purge relies on this) ---
$brokenManaged = New-MockCert -Thumbprint 'BROKEN1' -HasPrivateKey:$false `
    -FriendlyName 'SailPoint AD LDAPS (dc1.contoso.local)' -DnsNames @($fqdn)
$brokenUses = Test-AdLdapsCertificateUses -Certificate $brokenManaged
Assert-True (-not $brokenUses.Ok) 'managed cert without private key fails uses'
$foundBroken = Find-AdLdapsCertificate -DnsName @($fqdn) -RequiredDnsName $fqdn -Candidates @(
    [PSCustomObject]@{ Certificate = $brokenManaged; StoreName = 'My' }
) -Now $now
Assert-True ($null -eq $foundBroken.Certificate) 'broken managed cert is not selectable'

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