Public/Test-PersonaEndpoints.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Test-PersonaEndpoints {
    <#
    .SYNOPSIS
        Auth-gate regression matrix: hits critical API endpoints as each user
        persona and reports pass/fail per cell.
    .DESCRIPTION
        Background: t/1062, t/1063, t/1064 all shared the same anti-pattern —
        an auth gate was added that correctly restricted access but wasn't
        verified against all personas. A regular run of this matrix would
        catch that class of regression.
 
        Each cell tests "did the actual access (2xx vs 401/403) match the
        expected access for this persona?" Pass means the auth gate behaved
        as designed. Fail means either:
          - A gate was added that's too tight (operator getting 401/403 they
            shouldn't), or
          - A gate was removed that's too loose (operator getting 200 they
            shouldn't).
 
        Persona auth model:
          - anonymous — no auth headers; always testable
          - authenticated — requires X-Test-Persona header support (server t/1125)
          - admin — same; persona name must be in ADMIN_USERS
 
        Without -PersonaSecret, only the anonymous row runs and the
        authenticated/admin rows are marked 'skipped: needs server t/1125'.
        Once ServerAPI ships t/1125, set -PersonaSecret (or read from
        $env:TEST_PERSONA_SECRET) to enable the full matrix.
    .PARAMETER BaseUrl
        Target server URL.
    .PARAMETER Persona
        Test only the named persona(s). Default: all three.
    .PARAMETER Category
        Test only endpoints in this category. Default: all.
    .PARAMETER PersonaSecret
        Shared secret matching the server's TEST_PERSONA_SECRET. Enables
        admin/authenticated rows. Defaults to $env:TEST_PERSONA_SECRET.
    .PARAMETER Detailed
        Print a side-by-side matrix to host (in addition to the structured
        result objects).
    .PARAMETER TimeoutSec
        HTTP request timeout (1-60, default 15).
    .EXAMPLE
        Test-PersonaEndpoints
    .EXAMPLE
        # Once server t/1125 is in place:
        $env:TEST_PERSONA_SECRET = '<secret-from-vault>'
        Test-PersonaEndpoints -Detailed
    .EXAMPLE
        Test-PersonaEndpoints -Category Admin | Where-Object { -not $_.Pass }
    .EXAMPLE
        Test-PersonaEndpoints -Persona anonymous
    .LINK
        Show-AITriadHelp
    .LINK
        Test-TaxEditorHealth
    .LINK
        Test-TaxEditorEndpoints
    .LINK
        Test-AnonymousDebateFlow
    .LINK
        Test-ServiceWorkerHealth
    .LINK
        Get-FreeTierStatus
    .LINK
        Invoke-TaxEditorSmokeTest
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$BaseUrl = (Get-TaxEditorBaseUrl),

        [ValidateSet('anonymous', 'authenticated', 'admin')]
        [string[]]$Persona = @('anonymous', 'authenticated', 'admin'),

        [ValidateSet('AI', 'Data', 'Admin', 'Debug')]
        [string[]]$Category,

        [string]$PersonaSecret = $env:TEST_PERSONA_SECRET,

        [switch]$Detailed,

        [ValidateRange(1, 60)]
        [int]$TimeoutSec = 15
    )

    Set-StrictMode -Version Latest

    $BaseUrl = $BaseUrl.TrimEnd('/')

    # ── Endpoint × persona expectation table ─────────────────
    # Body shapes are minimal smoke payloads; we only care about the auth-gate
    # response, not the success-path data path.
    $Endpoints = @(
        @{ Method = 'POST'; Path = '/api/ai/generate';                            Cat = 'AI';
           Body = @{ prompt = 'noop'; maxTokens = 1 };
           Expected = @{ admin = $true; authenticated = $true; anonymous = $true } }
        @{ Method = 'POST'; Path = '/api/embeddings/compute';                     Cat = 'AI';
           Body = @{ texts = @('noop') };
           Expected = @{ admin = $true; authenticated = $true; anonymous = $true } }
        @{ Method = 'GET';  Path = '/api/flight-recorder/download-merged/none';   Cat = 'Debug';
           Body = $null;
           Expected = @{ admin = $true; authenticated = $false; anonymous = $false } }
        @{ Method = 'POST'; Path = '/api/flight-recorder/server-dump';            Cat = 'Debug';
           Body = @{ reason = 'persona-matrix' };
           Expected = @{ admin = $true; authenticated = $false; anonymous = $false } }
        @{ Method = 'GET';  Path = '/api/admin/review/stats';                     Cat = 'Admin';
           Body = $null;
           Expected = @{ admin = $true; authenticated = $false; anonymous = $false } }
        @{ Method = 'GET';  Path = '/api/taxonomy/accelerationist';               Cat = 'Data';
           Body = $null;
           Expected = @{ admin = $true; authenticated = $true; anonymous = $true } }
        @{ Method = 'POST'; Path = '/api/debates';                                Cat = 'Data';
           Body = @{ title = 'persona-matrix' };
           Expected = @{ admin = $true; authenticated = $true; anonymous = $true } }
    )

    if ($Category) {
        $Endpoints = @($Endpoints | Where-Object { $_.Cat -in $Category })
    }

    $Results = [System.Collections.Generic.List[PersonaEndpointTestResult]]::new()

    foreach ($Pers in $Persona) {
        $NeedsPersonaSecret = ($Pers -ne 'anonymous')
        $PersonaSkipNote = $null
        if ($NeedsPersonaSecret -and [string]::IsNullOrWhiteSpace($PersonaSecret)) {
            $PersonaSkipNote = 'skipped: no PersonaSecret — needs server t/1125 + $env:TEST_PERSONA_SECRET'
        }

        foreach ($Ep in $Endpoints) {
            $Expected = [bool]$Ep.Expected[$Pers]
            $R = [PersonaEndpointTestResult]::new()
            $R.Persona        = $Pers
            $R.Method         = $Ep.Method
            $R.Endpoint       = $Ep.Path
            $R.Category       = $Ep.Cat
            $R.ExpectedAccess = $Expected

            if ($PersonaSkipNote) {
                $R.Pass = $false
                $R.Note = $PersonaSkipNote
                $Results.Add($R)
                continue
            }

            Write-Verbose "[$Pers] $($Ep.Method) $($Ep.Path)"

            $Params = @{
                BaseUrl               = $BaseUrl
                Path                  = $Ep.Path
                Method                = $Ep.Method
                TimeoutSec            = $TimeoutSec
                AcceptableStatusCodes = @(200, 201, 204, 401, 403)
            }
            if ($null -ne $Ep.Body) { $Params.Body = $Ep.Body }
            if ($NeedsPersonaSecret) {
                $Params.ExtraHeaders = @{
                    'X-Test-Persona'        = $Pers
                    'X-Test-Persona-Secret' = $PersonaSecret
                }
            }

            $Check = Invoke-RemoteCheck @Params

            $Http2xx = ($Check.StatusCode -ge 200 -and $Check.StatusCode -lt 300)

            # t/1355 — classify the response body so we can distinguish "real API grant"
            # from "SPA-shell fall-through". A 2xx that returns text/html with a page shape
            # (DOCTYPE + Sign-In title) is the sign-in page, not real access — the request
            # never reached the intended handler.
            $CT = if ($Check.PSObject.Properties['ContentType']) { [string]$Check.ContentType } else { '' }
            $RawBody = if ($Check.PSObject.Properties['RawBody']) { [string]$Check.RawBody } else { '' }
            $IsHtml = $CT -match '^text/html'
            $IsShell = $IsHtml -and (
                ($RawBody -match '(?i)<title>\s*Sign In') -or
                ($RawBody -match '(?i)<!DOCTYPE\s+html')
            )
            $BodyKind = if ($null -ne $Check.Body) { 'json' }
                        elseif ($IsHtml)          { 'html' }
                        elseif ([string]::IsNullOrEmpty($RawBody)) { 'empty' }
                        else                      { 'unparsed' }

            # SPA-shell soft-pass — only applies when the persona was NOT expected to have
            # access, we got 2xx, and the body is the shell (not real data). The request
            # fell through to the sign-in page; the auth-gate contract is honored.
            # A 2xx with real JSON and ExpectedAccess=false STILL hard-fails — that's the
            # critical property this refinement must not break.
            $ActualAccess = $Http2xx
            $ShellNote = $null
            if (-not $Expected -and $Http2xx -and $IsShell) {
                $ActualAccess = $false
                $ShellNote = '200-but-SPA-shell — request fell through to sign-in page, no data leak'
            }

            $R.ActualAccess = $ActualAccess
            $R.Pass         = ($Expected -eq $ActualAccess)
            $R.StatusCode   = $Check.StatusCode
            $R.ContentType  = $CT
            $R.BodyKind     = $BodyKind
            $R.Ms           = $Check.ResponseMs
            $R.Error        = $Check.Error
            if ($ShellNote) { $R.Note = $ShellNote }
            $Results.Add($R)
        }
    }

    if ($Detailed) {
        $PersonaList = @($Persona | Sort-Object -Unique)
        $EndpointList = @($Endpoints | ForEach-Object { "$($_.Method) $($_.Path)" } | Select-Object -Unique)
        $PathWidth = [Math]::Min(48, ([Math]::Max(20, ($EndpointList | Measure-Object Length -Maximum).Maximum)))

        # Note: '-f' binds tighter than '+' — using string interpolation for the
        # width so the whole format string is one literal, not `'X' + $w + ('}' -f ...)`
        # which throws "Format item ends prematurely" (DevOps p/169#4).
        $Header = " {0,-$PathWidth}" -f 'Endpoint'
        foreach ($P in $PersonaList) { $Header += (' {0,-13}' -f $P) }
        Write-Host ''
        Write-Host $Header -ForegroundColor White
        Write-Host ((' ' + ('-' * $PathWidth)) + (' ' + ('-' * 13)) * $PersonaList.Count) -ForegroundColor DarkGray

        foreach ($Ep in $Endpoints) {
            $Label = "$($Ep.Method) $($Ep.Path)"
            if ($Label.Length -gt $PathWidth) { $Label = $Label.Substring(0, $PathWidth - 1) + '…' }
            $Line = " {0,-$PathWidth}" -f $Label
            $Color = 'Gray'
            foreach ($P in $PersonaList) {
                $Cell = @($Results | Where-Object { $_.Persona -eq $P -and $_.Endpoint -eq $Ep.Path })[0]
                if (-not $Cell) { $Line += ' {0,-13}' -f '-'; continue }
                $Mark = if ($Cell.Note) { 'skip' }
                        elseif ($Cell.Pass) { if ($Cell.ExpectedAccess) { 'OK (200)' } else { 'OK (gated)' } }
                        else { "FAIL ($($Cell.StatusCode))" }
                $Line += ' {0,-13}' -f $Mark
                if (-not $Cell.Pass -and -not $Cell.Note) { $Color = 'Red' }
            }
            Write-Host $Line -ForegroundColor $Color
        }
        Write-Host ''
    }

    $Total = @($Results).Count
    $Passed = @($Results | Where-Object { $_.Pass }).Count
    $Failed = @($Results | Where-Object { -not $_.Pass -and -not $_.Note }).Count
    $Skipped = @($Results | Where-Object { $_.Note }).Count
    $Color = if ($Failed -eq 0) { 'Green' } else { 'Red' }
    Write-Host ("Persona matrix: {0}/{1} pass, {2} fail, {3} skipped" -f $Passed, $Total, $Failed, $Skipped) -ForegroundColor $Color

    @($Results)
}