Private/CATestSuite.ps1

<#
.SYNOPSIS
    CA Manager - test-certificate suite generator (menu option 8). Requires Modules\CACore.ps1,
    Modules\CATemplates.ps1 (Get-CAManualApprovalTemplates), and Modules\CAInteractive.ps1
    dot-sourced, plus Request-VPNCertCore.ps1 (bundled next to CA-Manager.ps1 by
    Build-CAManagerZip.ps1; dev-time fallback to the repo copy under IPSEC AIO\MiscTools\Tools\).
 
.DESCRIPTION
    Lists the "Manual approval" (PEND_ALL_REQUESTS) templates actually published on the CA and lets
    the tech pick one or more from a numbered list (2026-09-15, per the maintainer - replaces the old single
    free-typed template name, now that per-group manual templates exist alongside the shared one).
    For each PICKED TEMPLATE, prompts for one representative AD username and issues a SET of two
    certs against it: one left VALID and one immediately REVOKED (reason 0 / unspecified) with a
    CRL republish. All PFX files share one password. Writes a <Company>_CertTestSuite_<ts>.txt
    summary that also lists the FortiGate cache-refresh commands (a revoked cert can still pass on
    the FortiGate until its CRL/OCSP cache turns over).
#>


# --- locate + dot-source the shared request engine ---
$script:__caCoreLoaded = $false
# NSP.PKI: the module ships it as (only) Private\Engine\Request-VPNCertCore.ps1.
foreach ($candidate in @(
    (Join-Path $PSScriptRoot "Engine\Request-VPNCertCore.ps1")
)) {
    if (Test-Path $candidate) { . $candidate; $script:__caCoreLoaded = $true; break }
}

function New-CATestCertSuite {
    <#
    .SYNOPSIS
        Menu option 8. Iterates user types -> one valid + one revoked cert each.
    .PARAMETER Status
        The Get-CAStatus object (for the issuance-readiness gate).
    .PARAMETER CAAnswers
        Optional baked-in answers (Company_Name, CA_TemplateManual) from CAAnswers.json.
    #>

    param(
        [Parameter(Mandatory)]$Status,
        $CAAnswers
    )

    Write-CAHeader "Generate a test-certificate suite"

    if (-not $script:__caCoreLoaded) {
        Write-Host "Request-VPNCertCore.ps1 was not found - cannot issue certs." -ForegroundColor Red
        Write-Host "(Expected next to CA-Manager.ps1 in a built CAManager.zip, or under" -ForegroundColor Gray
        Write-Host " IPSEC AIO\MiscTools\Tools\ when running from the repo.)" -ForegroundColor Gray
        Read-Host "Press Enter to return to the menu" | Out-Null
        return
    }

    # --- order gate: issued certs bake in whatever revocation URLs are set right now ---
    if (-not $Status.IssuanceReady) {
        Write-Host "Issuance readiness is NOT READY." -ForegroundColor Yellow
        Write-Host "Any certificate issued now bakes in whatever AIA / CDP / OCSP URLs the CA" -ForegroundColor Yellow
        Write-Host "currently has - which won't be the App Proxy URLs yet. Test certs issued now" -ForegroundColor Yellow
        Write-Host "will not validate the same way real ones will after routing is finished." -ForegroundColor Yellow
        Write-Host ""
        $ack = Read-Host "Type YES (all caps) to issue test certs anyway, anything else to cancel"
        if ($ack -cne 'YES') { Write-Host "Cancelled." -ForegroundColor Gray; Read-Host "Press Enter" | Out-Null; return }
    }

    # --- template(s): numbered pick-list of published "Manual approval" templates -----------------
    # 2026-09-15, per the maintainer: used to be one free-typed name (always the single shared
    # IKEv2VPN-InternalUsers-MANUAL) - now that per-group manual templates exist (one per RadiusGroupPair,
    # Get-CAPerGroupManualTemplateSpec), show what's actually published and let the tech pick.
    $manualTemplates = @(Get-CAManualApprovalTemplates)
    if (-not $manualTemplates.Count) {
        Write-Host "No published templates with 'Manual approval' (PEND_ALL_REQUESTS) found on this CA." -ForegroundColor Yellow
        Write-Host "Run menu 4 first to create/publish a Manual (or per-group -MANUAL) template." -ForegroundColor Yellow
        Read-Host "Press Enter to return to the menu" | Out-Null
        return
    }
    Write-Host ""
    Write-Host " Manual-approval templates published on this CA:" -ForegroundColor Cyan
    for ($i = 0; $i -lt $manualTemplates.Count; $i++) { Write-Host (" [{0}] {1}" -f ($i + 1), $manualTemplates[$i].DisplayName) -ForegroundColor Gray }
    Write-Host ""
    # 2026-09-15, per the maintainer: "can we add a 'back'/'main menu' option here? and anywhere else we've
    # missed it" - this pick-list used to only cancel implicitly (blank/garbage input happened to
    # fall through to "no valid selection"), with no discoverable way to bail shown in the prompt
    # itself. 'B' is now explicit here and at every other prompt below in this function.
    $pickRaw = Read-Host " Which template(s) to test? Comma-separated numbers, 'A' for all $($manualTemplates.Count), or 'B' to cancel and return to the menu"
    if ($pickRaw.Trim() -match '^(?i)b(ack)?$') { Write-Host " Cancelled." -ForegroundColor Gray; return }
    $pickedTemplates = @(if ($pickRaw.Trim() -match '^(?i)a(ll)?$') {
        $manualTemplates
    } else {
        $pickRaw -split '[,\s]+' | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $manualTemplates.Count } | ForEach-Object { $manualTemplates[$_] }
    })
    if (-not $pickedTemplates.Count) {
        Write-Host " No valid selection - cancelled." -ForegroundColor Yellow
        Read-Host "Press Enter to return to the menu" | Out-Null
        return
    }

    # --- output dir ---
    $outDir = Read-Host "Output directory for the PFX files [C:\Admin\TestPFX], or 'B' to cancel"
    if ($outDir.Trim() -match '^(?i)b(ack)?$') { Write-Host " Cancelled." -ForegroundColor Gray; return }
    if ([string]::IsNullOrWhiteSpace($outDir)) { $outDir = 'C:\Admin\TestPFX' }
    if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }

    # --- shared PFX password (asked once, confirmed) ---
    # 'B' is checked against the FIRST prompt only (decrypted just for this one comparison) - a real
    # PFX password being the literal single word "b"/"back" is vanishingly unlikely for an admin-only
    # test-cert tool, and matching every other prompt's cancel keyword here beats leaving this loop
    # as the one place in the whole wizard with no way out short of Ctrl+C.
    while ($true) {
        $pw1 = Read-Host "Shared PFX password for EVERY test cert (or 'B' to cancel)" -AsSecureString
        if (([System.Net.NetworkCredential]::new('', $pw1).Password) -match '^(?i)b(ack)?$') { Write-Host " Cancelled." -ForegroundColor Gray; return }
        $pw2 = Read-Host "Confirm PFX password" -AsSecureString
        if (Test-SecureStringMatch $pw1 $pw2) { $pfxPassword = $pw1; break }
        Write-Host "Passwords did not match - try again." -ForegroundColor Yellow
    }

    # --- for each picked template, prompt for one representative AD username ---------------------
    # 2026-09-15, per the maintainer: "as it cycles through the picked ones we'll need to prompt for
    # username" - one username per SELECTED TEMPLATE (a per-group manual template already names its
    # own group in its DisplayName, so that IS the "type" now - no separate free-typed label needed).
    Write-Host ""
    Write-Host "For each selected template, one representative AD username - each set = one VALID cert" -ForegroundColor Cyan
    Write-Host "+ one REVOKED cert issued from THAT template." -ForegroundColor Cyan
    $types = @()
    foreach ($tmpl in $pickedTemplates) {
        Write-Host ""
        Write-Host ("--- $($tmpl.DisplayName) ---") -ForegroundColor White
        # 2026-09-15, bug found in the field + the maintainer's own follow-up: "if it finds 0, return and have the
        # tech re-do the search" - a 0-match search must NOT be treated the same as the tech
        # deliberately blanking out to skip this template. -ReturnNullOnNoMatch makes
        # Find-VPNCertADUser hand back $null (instead of looping with its own generic reprompt)
        # right after printing "No users matched" - so THIS loop re-shows the real "blank to skip
        # this template" prompt for the SAME template, rather than silently moving on.
        $user = $null
        $cancelled = $false
        while ($true) {
            $seed = Read-Host " Representative AD username to search for (blank to skip this template, 'B' to cancel this whole run)"
            if ($seed.Trim() -match '^(?i)b(ack)?$') { Write-Host " Cancelled." -ForegroundColor Gray; $cancelled = $true; break }
            if ([string]::IsNullOrWhiteSpace($seed)) { Write-Host " Skipped." -ForegroundColor Gray; break }
            $user = Find-VPNCertADUser -SearchTerm $seed -ReturnNullOnNoMatch
            if ($user) { break }
        }
        if ($cancelled) { return }
        if (-not $user) { continue }
        Write-Host (" -> {0} ({1})" -f $user.SamAccountName, $user.UserPrincipalName) -ForegroundColor Gray
        $types += [pscustomobject]@{ Label = $tmpl.DisplayName; TemplateName = $tmpl.Cn; User = $user }
    }
    if (-not $types) { Write-Host "Nothing to do." -ForegroundColor Gray; Read-Host "Press Enter" | Out-Null; return }

    # --- PASS 1: submit every request, retrieve nothing yet -----------------------------------
    $results = New-Object System.Collections.Generic.List[object]
    $pending = New-Object System.Collections.Generic.List[object]
    foreach ($t in $types) {
        foreach ($kind in @('valid', 'revoked')) {
            Write-Host ""
            Write-Host ("=== {0} / {1} : submitting request for {2} ===" -f $t.Label, $kind, $t.User.SamAccountName) -ForegroundColor Cyan
            $req = New-VPNCertRequest -ADUser $t.User -TemplateName $t.TemplateName
            $row = [pscustomobject]@{
                Label = $t.Label; Kind = $kind; User = $t.User.SamAccountName
                Request = $req; Status = $(if ($req) { 'Submitted' } else { 'RequestFailed' })
                PfxPath = $null; CerPath = $null; Thumbprint = $null; SerialNumber = $null; Revoked = $false
            }
            $results.Add($row)
            if ($req) { $pending.Add($row) }
        }
    }

    # --- ONE approval pass --------------------------------------------------------------------
    if ($pending.Count) {
        Write-Host ""
        Write-Host "==================== APPROVE ALL OF THESE ====================" -ForegroundColor Yellow
        Write-Host (" {0,-24} {1,-8} {2,-22} {3}" -f 'Template', 'Kind', 'User', 'Requested subject') -ForegroundColor Gray
        foreach ($r in $pending) {
            Write-Host (" {0,-24} {1,-8} {2,-22} {3}" -f $r.Label, $r.Kind, $r.User, $r.Request.Request.Subject) -ForegroundColor Gray
        }
        Write-Host ""
        Write-Host " certsrv.msc -> Pending Requests -> select all -> right-click -> All Tasks -> Issue" -ForegroundColor Yellow
        Read-Host " Press Enter once ALL $($pending.Count) request(s) are issued" | Out-Null
    }

    # --- PASS 2: retrieve + export, then revoke the 'revoked' set, then ONE CRL republish -----
    $anyRevoked = $false
    foreach ($row in $pending) {
        $stem = "{0}_{1}_{2}" -f ($row.User -replace '[^A-Za-z0-9]', ''), ($row.Label -replace '[^A-Za-z0-9]', ''), $row.Kind
        $res = Complete-VPNCertRequest -RequestObject $row.Request -OutputDir $outDir -PfxPassword $pfxPassword -FileNameStem $stem
        $row.Status       = $res.Status
        $row.PfxPath      = $res.PfxPath
        $row.CerPath      = $res.CerPath
        $row.Thumbprint   = $res.Thumbprint
        $row.SerialNumber = $res.SerialNumber

        if ($row.Kind -eq 'revoked' -and $res.SerialNumber) {
            Write-Host ("Revoking serial {0} (reason 0 / unspecified)..." -f $res.SerialNumber) -ForegroundColor Yellow
            $rev = Revoke-VPNCert -SerialNumber $res.SerialNumber -ReasonCode 0
            $row.Revoked = [bool]$rev.Revoked
            if ($rev.Revoked) { $anyRevoked = $true }
            else { Write-Host " (revoke did not confirm - see summary)" -ForegroundColor Yellow }
        }
    }
    if ($anyRevoked) {
        Write-Host "Republishing the CRL once for the whole revoked set..." -ForegroundColor Yellow
        & certutil.exe -crl 2>&1 | Out-String | Write-Verbose
        if ($LASTEXITCODE -ne 0) { Write-Host " certutil -crl reported exit $LASTEXITCODE - republish the CRL by hand." -ForegroundColor Yellow }
    }
    # drop the transient Request handle before the summary
    foreach ($r in $results) { $r.PSObject.Properties.Remove('Request') }

    # --- summary file ---
    $company = if ($CAAnswers -and -not [string]::IsNullOrWhiteSpace($CAAnswers.Company_Name)) { $CAAnswers.Company_Name }
               elseif ($Status.CACommonName) { $Status.CACommonName } else { 'CA' }
    $stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
    $summaryPath = Join-Path $outDir ("{0}_CertTestSuite_{1}.txt" -f ($company -replace '[^A-Za-z0-9]', ''), $stamp)

    $lines = New-Object System.Collections.Generic.List[string]
    $lines.Add("Certificate test suite - $company")
    $lines.Add("Generated: $(Get-Date)")
    $lines.Add("Templates: " + (($pickedTemplates | ForEach-Object { $_.DisplayName }) -join ', '))
    $lines.Add("Output : $outDir (all PFX files share the one password you entered)")
    $lines.Add("")
    $lines.Add(("{0,-24} {1,-8} {2,-22} {3,-9} {4,-8} {5}" -f 'Template', 'Kind', 'User', 'Status', 'Revoked', 'PFX'))
    $lines.Add(("-" * 110))
    foreach ($r in $results) {
        $lines.Add(("{0,-24} {1,-8} {2,-22} {3,-9} {4,-8} {5}" -f $r.Label, $r.Kind, $r.User, $r.Status, $r.Revoked, $(if ($r.PfxPath) { $r.PfxPath } else { '(not exported)' })))
        if ($r.SerialNumber) { $lines.Add(("{0,-24} {1,-8} serial {2} thumbprint {3}" -f '', '', $r.SerialNumber, $r.Thumbprint)) }
        # 2026-09-15, per the maintainer: "why didn't I have you do it during generation time" - the public
        # .cer (no private key) is exported right alongside the PFX now (Complete-VPNCertRequest),
        # not as a separate later step. Listed here so the summary is the one place that names both.
        if ($r.CerPath) { $lines.Add(("{0,-24} {1,-8} cer {2}" -f '', '', $r.CerPath)) }
    }
    $lines.Add("")
    $lines.Add("=== FortiGate: after revoking, a revoked cert may still pass until the CRL/OCSP cache turns over ===")
    $lines.Add(" execute vpn certificate crl update <crl-name> # force a fresh CRL download now")
    $lines.Add(" diagnose vpn ike gateway flush # drop cached IKE SAs (clients must reconnect)")
    $lines.Add(" # OCSP has its own cache - check 'config vpn certificate ocsp-server' timeout, or bounce the IKE daemon.")
    $lines.Add("Verify: reconnect with the REVOKED PFX - phase 1 should now fail with a certificate-revoked error.")
    Set-Content -Path $summaryPath -Value $lines -Encoding UTF8

    Write-Host ""
    Write-Host "Summary written: $summaryPath" -ForegroundColor Green
    $issued = @($results | Where-Object { $_.Status -eq 'Issued' }).Count
    $failed = @($results | Where-Object { $_.Status -notin @('Issued') }).Count
    $color  = if ($issued -eq $results.Count) { 'Green' } elseif ($issued) { 'Yellow' } else { 'Red' }
    Write-Host ("Issued {0} / {1} requested across {2} template(s){3}." -f $issued, $results.Count, $types.Count,
        $(if ($failed) { " - $failed failed, see the table above" })) -ForegroundColor $color
    Read-Host "Press Enter to return to the menu" | Out-Null
}