Public/Test-TaxEditorEndpoints.ps1

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

function Test-TaxEditorEndpoints {
    <#
    .SYNOPSIS
        Smoke-tests key API endpoints on the deployed Taxonomy Editor.
    .DESCRIPTION
        Hits a curated set of GET endpoints that are publicly accessible (or
        accessible to anonymous users) and validates HTTP status codes and
        basic response shape. Returns per-endpoint results.
    .PARAMETER BaseUrl
        The base URL of the deployed Taxonomy Editor site.
    .PARAMETER TimeoutSec
        HTTP request timeout in seconds per endpoint. Default: 15.
    .PARAMETER Category
        Filter to a specific category of endpoints. Default: all.
    .PARAMETER AnonymousSession
        Establish an anonymous WebRequestSession against /.auth/anonymous
        (Azure Easy Auth) via New-AnonymousWebSession and pass it as
        -Session on every endpoint check. This replicates the anon cookie
        jar the deploy-time bash acceptance test used to inline (t/1500
        Phase 3, e/41). If the session establishment fails (Easy Auth
        endpoint missing / non-2xx), the switch degrades gracefully to
        unauthenticated requests with a warning — the caller then sees
        whichever endpoints require auth flip to failure, which is the
        signal.
    .EXAMPLE
        Test-TaxEditorEndpoints
    .EXAMPLE
        Test-TaxEditorEndpoints -Category Data | Format-Table
    .EXAMPLE
        # Deploy-time acceptance smoke — anon cookie jar + full endpoint set.
        Test-TaxEditorEndpoints -AnonymousSession | Where-Object { -not $_.Pass }
    .LINK
        Show-AITriadHelp
    .LINK
        Test-TaxEditorHealth
    .LINK
        Test-AnonymousDebateFlow
    .LINK
        Test-PersonaEndpoints
    .LINK
        Test-ServiceWorkerHealth
    .LINK
        Get-FreeTierStatus
    .LINK
        Invoke-TaxEditorSmokeTest
    #>

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

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

        [Parameter()]
        [ValidateSet('Health', 'Data', 'Auth', 'Models', 'Metadata', 'Static', 'Debate', 'Community', 'Sync', 'Frontend')]
        [string]$Category,

        [Parameter()]
        [switch]$AnonymousSession
    )

    Set-StrictMode -Version Latest

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

    $Endpoints = @(
        @{ Path = '/healthz';                  Cat = 'Health';    Field = 'status';   Desc = 'Liveness probe' }
        @{ Path = '/health';                   Cat = 'Health';    Field = 'status';   Desc = 'Readiness probe' }
        @{ Path = '/api/auth/me';              Cat = 'Auth';      Field = $null;      Desc = 'Auth identity' }
        @{ Path = '/api/models';               Cat = 'Models';    Field = $null;      Desc = 'Available AI models' }
        # AuthGated = $true marks routes behind Azure Easy Auth. To an anonymous
        # smoke-test caller they serve the Sign-In HTML interstitial (200 text/html),
        # which the t/1474 ExpectJson guard flags as failure — the loop below
        # reclassifies that specific interstitial to PASS (t/1657). Public routes
        # (no key) keep the strict JSON guard, so a public route regressing to
        # require-auth still fails. Route set empirically classified against prod.
        @{ Path = '/api/taxonomy/accelerationist'; Cat = 'Data';  Field = 'nodes';    AuthGated = $true;  Desc = 'Accelerationist taxonomy' }
        @{ Path = '/api/taxonomy/safetyist';   Cat = 'Data';      Field = 'nodes';    AuthGated = $true;  Desc = 'Safetyist taxonomy' }
        @{ Path = '/api/taxonomy/skeptic';     Cat = 'Data';      Field = 'nodes';    AuthGated = $true;  Desc = 'Skeptic taxonomy' }
        @{ Path = '/api/edges';                Cat = 'Data';      Field = $null;      AuthGated = $true;  Desc = 'Edge relationships' }
        @{ Path = '/api/conflicts';            Cat = 'Data';      Field = $null;      AuthGated = $true;  Desc = 'Conflict data' }
        @{ Path = '/api/policy-registry';      Cat = 'Metadata';  Field = $null;      AuthGated = $true;  Desc = 'Policy action registry' }
        @{ Path = '/api/sources';              Cat = 'Metadata';  Field = $null;      AuthGated = $true;  Desc = 'Source document index' }
        @{ Path = '/api/lineage-categories';   Cat = 'Metadata';  Field = $null;      AuthGated = $true;  Desc = 'Lineage categories' }
        @{ Path = '/api/dictionary';           Cat = 'Metadata';  Field = $null;      AuthGated = $true;  Desc = 'Project dictionary' }
        @{ Path = '/api/backends/available';   Cat = 'Models';    Field = $null;      AuthGated = $true;  Desc = 'Available AI backends' }
        @{ Path = '/api/proxy/tier';           Cat = 'Auth';      Field = $null;      AuthGated = $true;  Desc = 'Proxy tier info' }
        @{ Path = '/third-party-notices';      Cat = 'Static';    Field = $null;      AuthGated = $true;  Desc = 'Third-party notices page' }
        # t/1500 Phase 3 — deploy-time acceptance additions (e/41).
        @{ Path = '/api/data/available';       Cat = 'Health';    Field = $null;      Desc = 'Data availability flag' }
        @{ Path = '/api/debates/list';         Cat = 'Debate';    Field = $null;      AuthGated = $true;  Desc = 'Debate listing' }
        @{ Path = '/api/chats';                Cat = 'Debate';    Field = $null;      AuthGated = $true;  Desc = 'Chat listing' }
        @{ Path = '/api/community/debates';    Cat = 'Community'; Field = $null;      AuthGated = $true;  Desc = 'Community debates' }
        @{ Path = '/api/sync/status';          Cat = 'Sync';      Field = 'enabled';  AuthGated = $true;  Desc = 'Sync status' }
        # SPA-shell check — GET / returns HTML; success requires the React root
        # div AND a script tag, so it catches both a blank shell and a
        # bad-cache HTML/500 fallback. Kept as its own category so callers can
        # bypass it via -Category. Handled specially in the loop below
        # (Field='__spa' triggers the raw-body content check).
        # AuthGated (t/1657) — '/' sits behind Azure Easy Auth too, so an
        # anonymous smoke caller gets the same Sign-In interstitial as the API
        # routes; the reclassify block restores it to PASS. A genuinely-broken
        # shell (non-interstitial HTML) still fails the SPA-shell check below.
        @{ Path = '/';                         Cat = 'Frontend';  Field = '__spa';    AuthGated = $true;  Desc = 'SPA shell (root document)' }
    )

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

    # Anonymous session establishment (t/1500 Phase 3, TL note 1).
    $Session = $null
    if ($AnonymousSession) {
        $Session = New-AnonymousWebSession -BaseUrl $BaseUrl -TimeoutSec $TimeoutSec
        if (-not $Session) {
            Write-Warning "AnonymousSession: /.auth/anonymous did not establish a session at $BaseUrl; proceeding unauthenticated."
        }
    }

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

    foreach ($Ep in $Endpoints) {
        Write-Verbose "Testing $($Ep.Path) ($($Ep.Desc))..."

        $Params = @{
            BaseUrl    = $BaseUrl
            Path       = $Ep.Path
            TimeoutSec = $TimeoutSec
            AcceptableStatusCodes = @(200, 304)
        }
        # SPA shell: expect HTML, validate raw body shape after the request.
        # Everything else: JSON with optional expected field.
        if ($Ep.Field -eq '__spa') {
            # Do NOT set ExpectJson — SPA endpoint legitimately returns HTML.
        } else {
            if ($Ep.Field) { $Params.ExpectedField = $Ep.Field }
            # t/1474 — flag HTML-200 as failure for JSON endpoints. Static endpoints
            # (e.g. /third-party-notices) legitimately return HTML, so they're excluded.
            if ($Ep.Cat -ne 'Static') { $Params.ExpectJson = $true }
        }
        if ($Session) { $Params.Session = $Session }

        $Check = Invoke-RemoteCheck @Params

        # SPA-shell validation: root-div + script-tag both present.
        # If either is missing (blank shell, cache-miss HTML), flip Pass=$false.
        # For the AuthGated '/' route an anonymous caller receives the Easy Auth
        # Sign-In interstitial here too — that HTML has no root div/script, so this
        # flips it to failure; the reclassify block BELOW (which runs after this,
        # so it observes the flipped state) restores it to PASS on the interstitial
        # title. Order matters: the '/' row sets no ExpectJson, so Invoke-RemoteCheck
        # returns Success=$true for the HTML-200; the reclassify guard (-not Success)
        # only fires once this SPA check has failed it. A genuinely broken shell
        # (non-interstitial HTML) has no title match → stays failed (gate integrity).
        if ($Check.Success -and $Ep.Field -eq '__spa') {
            $HasRootDiv = $Check.RawBody -match '<div\s+id="root"'
            $HasScript  = $Check.RawBody -match 'src="[^"]+\.js"'
            if (-not ($HasRootDiv -and $HasScript)) {
                $Check = [PSCustomObject]@{
                    Success     = $false
                    StatusCode  = $Check.StatusCode
                    ResponseMs  = $Check.ResponseMs
                    Body        = $Check.Body
                    ContentType = $Check.ContentType
                    RawBody     = $Check.RawBody
                    Error       = "SPA shell missing root div or script tag (root-div=$HasRootDiv, script=$HasScript)"
                }
            }
        }

        # t/1657 — an auth-gated route serving the Azure Easy Auth Sign-In
        # interstitial (200 text/html) to an anonymous smoke-test caller is
        # EXPECTED (auth-gating is working), not a failure. For JSON routes the
        # t/1474 ExpectJson guard flags it; for the '/' SPA route the SPA-shell
        # check above flags it. Reclassify to PASS ONLY when the route is AuthGated
        # AND the body carries the interstitial title. A down route returns a 5xx
        # or a different body → no title match → still fails (gate-signal integrity).
        # The dash between "Sign In" and "AITriad" varies by platform encoding
        # (hyphen / en-dash / em-dash); match any so the anchor tokens carry the signal.
        if (-not $Check.Success -and $Ep.ContainsKey('AuthGated') -and $Ep.AuthGated -and
            $Check.RawBody -match 'Sign In\s*[-–—]\s*AITriad Taxonomy Editor') {
            $Check = [PSCustomObject]@{
                Success     = $true
                StatusCode  = $Check.StatusCode
                ResponseMs  = $Check.ResponseMs
                Body        = $null
                ContentType = $Check.ContentType
                RawBody     = $Check.RawBody
                Error       = $null
            }
        }

        $NodeCount = $null
        if ($Check.Success -and $Check.Body -and $Ep.Field -eq 'nodes') {
            $NodeCount = @($Check.Body.nodes).Count
        }

        $Result = [EndpointTestResult]::new()
        $Result.Endpoint    = $Ep.Path
        $Result.Category    = $Ep.Cat
        $Result.Description = $Ep.Desc
        $Result.Status      = $Check.StatusCode
        $Result.Pass        = $Check.Success
        $Result.Ms          = $Check.ResponseMs
        $Result.NodeCount   = $NodeCount
        $Result.Error       = $Check.Error
        $Results.Add($Result)
    }

    @($Results)
}