tests/Report.React.Tests.ps1

#Requires -Version 7.0
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }

<#
    Pester tests for src/report/renderers/Export-React.ps1 — the self-contained
    interactive HTML report renderer (ADO Story AB#5053). Renders against a
    synthetic scored Findings set (built the same way
    tests/Assessment.Engine.Tests.ps1 does, via Get-Score) plus the repo's
    existing tests/datadump/sample-collect.json fixture for -Collect. No Azure
    connection, no external network access required.
#>


BeforeAll {
    $script:Root = Split-Path $PSScriptRoot -Parent
    . "$script:Root/src/assess/engine/Get-Score.ps1"
    . "$script:Root/src/report/renderers/Export-React.ps1"

    function New-ReactTestFinding {
        param($Id, $Framework, $Area, $Status, $Severity = 'medium', $Weight = 1.0)
        [pscustomobject]@{
            Id = $Id; Title = "$Id title"; Framework = $Framework; Area = $Area; Severity = $Severity
            Status = $Status; EvidenceCount = 2; Evidence = @(); Remediation = "Remediate $Id."
            Manual = ($Status -eq 'Manual'); AreaWeight = $Weight
        }
    }

    $script:Findings = @(
        (New-ReactTestFinding 'net-1' 'CAF' 'Networking' 'Pass' 'low')
        (New-ReactTestFinding 'net-2' 'CAF' 'Networking' 'Fail' 'high')
        (New-ReactTestFinding 'sec-1' 'WAF' 'Security' 'Partial' 'medium')
        (New-ReactTestFinding 'sec-2' 'WAF' 'Security' 'Manual')
        (New-ReactTestFinding 'sec-3' 'WAF' 'Security' 'Unknown')
    )
    $script:Scored = Get-Score -Findings $script:Findings

    # Mirrors the REAL shape Invoke-ScoutAssessment passes to Export-Report/
    # Export-React: the canonical collect.json object itself (networking/
    # compute/governance/tags at the top level), with `_meta` sitting
    # alongside those keys — not wrapped under a `.raw` property.
    $script:CollectPath = Join-Path $script:Root 'tests' 'datadump' 'sample-collect.json'
    $script:Collect = Get-Content $script:CollectPath -Raw | ConvertFrom-Json -Depth 100
    $script:Collect | Add-Member -NotePropertyName _meta -NotePropertyValue ([pscustomobject]@{
            scope = 'ArmOnly'; managementGroupId = 'mg-test-01'; generatedOn = (Get-Date).ToString('o')
        }) -Force

    $script:OutDir = Join-Path $script:Root 'tests' 'test-output' 'react'
    if (Test-Path $script:OutDir) { Remove-Item $script:OutDir -Recurse -Force }
    New-Item -ItemType Directory -Path $script:OutDir -Force | Out-Null
}

AfterAll {
    if (Test-Path $script:OutDir) { Remove-Item $script:OutDir -Recurse -Force -ErrorAction SilentlyContinue }
}

Describe 'Export-React — without drift' {
    BeforeAll {
        $script:ReportPath = Export-React -Findings $script:Scored -Collect $script:Collect -OutputPath $script:OutDir
        $script:Html = Get-Content $script:ReportPath -Raw
    }

    It 'writes report-react.html into -OutputPath and returns its path' {
        $script:ReportPath | Should -Exist
        (Split-Path $script:ReportPath -Leaf) | Should -Be 'report-react.html'
    }

    It 'embeds the findings JSON blob under window.__SCOUT_DATA__' {
        $script:Html | Should -Match '__SCOUT_DATA__'
        $script:Html | Should -Match 'GeneratedOn'
        $script:Html | Should -Match 'net-1'
    }

    It 'embedded data is valid, parseable JSON containing every synthetic finding Id' {
        $match = [regex]::Match($script:Html, 'window\.__SCOUT_DATA__ = (\{.*?\});(?=\s*</script>)', [System.Text.RegularExpressions.RegexOptions]::Singleline)
        $match.Success | Should -BeTrue
        $embedded = $match.Groups[1].Value | ConvertFrom-Json -Depth 100
        $embedded.Findings.Findings.Count | Should -Be 5
        ($embedded.Findings.Findings.Id | Sort-Object) | Should -Be (@('net-1', 'net-2', 'sec-1', 'sec-2', 'sec-3') | Sort-Object)
        $embedded.Meta.Scope | Should -Be 'ArmOnly'
        $embedded.Meta.ManagementGroupId | Should -Be 'mg-test-01'
        $embedded.Drift | Should -BeNullOrEmpty
    }

    It 'has no external CDN / network references (self-contained offline artifact)' {
        $script:Html | Should -Not -Match 'src\s*=\s*"http'
        $script:Html | Should -Not -Match "src\s*=\s*'http"
        $script:Html | Should -Not -Match 'href\s*=\s*"http'
        $script:Html | Should -Not -Match "href\s*=\s*'http"
        $script:Html | Should -Not -Match 'cdn\.'
    }

    It 'is a single self-contained file (inline styles and scripts, no linked css or js)' {
        $script:Html | Should -Match '<style>'
        $script:Html | Should -Match '<script>'
        $script:Html | Should -Not -Match '<link[^>]+rel="stylesheet"'
        $script:Html | Should -Not -Match '<script[^>]+src='
    }

    It 'embeds a null Drift so the client-side "if(DRIFT)" guard skips the tab' {
        $script:Html | Should -Match '"Drift":\s*null'
    }
}

Describe 'Export-React — with drift embedded' {
    BeforeAll {
        $script:DriftObject = [pscustomobject]@{
            RunId         = 'run2'
            GeneratedOn   = (Get-Date).ToString('o')
            IsBaseline    = $false
            PreviousRunId = 'run1'
            OverallScore  = 67
            PreviousScore = 50
            ScoreDelta    = 17
            Summary       = [pscustomobject]@{ New = 1; Resolved = 1; Regressed = 0; Unchanged = 2; Removed = 0 }
            Findings      = @(
                [pscustomobject]@{ Id = 'net-2'; Title = 'net-2 title'; Framework = 'CAF'; Area = 'Networking'; PreviousStatus = 'Fail'; CurrentStatus = 'Pass'; Drift = 'Resolved' }
            )
        }
        $script:ReportPath = Export-React -Findings $script:Scored -Collect $script:Collect -OutputPath $script:OutDir -Drift $script:DriftObject
        $script:Html = Get-Content $script:ReportPath -Raw
    }

    It 'embeds the drift object and includes the client-side Drift tab capability' {
        # The Drift tab is only pushed into the DOM at runtime by client JS (no
        # headless browser here to execute it), so assert on the two things a
        # static read of the file can actually prove: (1) the template's JS
        # conditionally renders a drift tab whenever DRIFT is truthy, and
        # (2) this run's embedded payload actually carries a truthy Drift
        # object with the expected fields serialized in — together those
        # guarantee a browser opening this file renders the tab.
        $script:Html | Should -Match "tabs\.push\(\['drift'"
        $script:Html | Should -Match '"PreviousRunId":\s*"run1"'
        $script:Html | Should -Match '"RunId":\s*"run2"'
    }

    It 'embedded Drift payload parses with the expected summary counts' {
        $match = [regex]::Match($script:Html, 'window\.__SCOUT_DATA__ = (\{.*?\});(?=\s*</script>)', [System.Text.RegularExpressions.RegexOptions]::Singleline)
        $embedded = $match.Groups[1].Value | ConvertFrom-Json -Depth 100
        $embedded.Drift | Should -Not -BeNullOrEmpty
        $embedded.Drift.IsBaseline | Should -BeFalse
        $embedded.Drift.Summary.Resolved | Should -Be 1
    }

    It 'remains free of external CDN references with drift embedded' {
        $script:Html | Should -Not -Match 'src\s*=\s*"http'
        $script:Html | Should -Not -Match 'href\s*=\s*"http'
    }
}

<#
    Environment-visuals feature trio (AB#376-378, 380, 386-393): interactive VNet
    topology + click-to-details + reset/fit, MG-hierarchy diagram, per-section
    search/filter, clickable rows + side panel, inventory KPI cards, Azure
    Firewall drill-down, Governance section (budgets/locks/tag chips), policy
    enforcement badge, scope tooltips. sample-collect.json already carries real
    networking/compute/governance/tags data, so -Collect is passed as-is (the
    canonical collect.json shape, mirroring Invoke-ScoutAssessment) to prove the
    renderer reads it out correctly. There is no headless browser in this test
    run, so — matching the existing Drift-tab test's approach above — assertions
    check (1) the embedded JSON payload actually carries the expected data, and
    (2) the template's client-side JS source contains the conditional
    render/hide logic that would act on it in a real browser. The client-side
    render *logic itself* (topology node/edge construction, MG-tree building,
    truncation, enforcement-badge mapping, dual-shape property reads) is
    exercised for real in a plain Node harness — see the PR/session notes for
    the exact commands run.
#>

Describe 'Export-React — environment visuals (AB#376-378, 380, 386-393)' {
    BeforeAll {
        $script:EnvReportPath = Export-React -Findings $script:Scored -Collect $script:Collect -OutputPath $script:OutDir
        $script:EnvHtml = Get-Content $script:EnvReportPath -Raw
        $envMatch = [regex]::Match($script:EnvHtml, 'window\.__SCOUT_DATA__ = (\{.*?\});(?=\s*</script>)', [System.Text.RegularExpressions.RegexOptions]::Singleline)
        $envMatch.Success | Should -BeTrue
        $script:EnvEmbedded = $envMatch.Groups[1].Value | ConvertFrom-Json -Depth 100
    }

    It 'embeds the Networking slice from Collect (AB#376 topology data source)' {
        $script:EnvEmbedded.Networking.VirtualNetworks.Count | Should -Be 3
        ($script:EnvEmbedded.Networking.VirtualNetworks.name | Sort-Object) | Should -Be (@('vnet-hub', 'vnet-isolated', 'vnet-spoke1') | Sort-Object)
        $script:EnvEmbedded.Networking.Subnets.Count | Should -Be 3
        $script:EnvEmbedded.Networking.AzureFirewalls[0].name | Should -Be 'afw-hub'
    }

    It 'embeds the Compute and Governance slices from Collect (AB#380/391 data sources)' {
        $script:EnvEmbedded.Compute.VirtualMachines.Count | Should -Be 3
        $script:EnvEmbedded.Governance.ManagementGroups.Count | Should -Be 3
        $script:EnvEmbedded.Governance.PolicyAssignments.Count | Should -Be 3
        $script:EnvEmbedded.Governance.Budgets.Count | Should -Be 2
        $script:EnvEmbedded.Governance.ResourceLocks.Count | Should -Be 1
        $script:EnvEmbedded.Tags.Count | Should -Be 2
    }

    It 'embeds the raw policy-assignment enforcementMode values the client-side badge maps (AB#392)' {
        $modes = $script:EnvEmbedded.Governance.PolicyAssignments.properties.enforcementMode
        $modes | Should -Contain 'Default'
        $modes | Should -Contain 'DoNotEnforce'
    }

    It 'the template JS defines the topology/governance renderers and their supporting helpers' {
        $script:EnvHtml | Should -Match 'function buildTopology\(\)'
        $script:EnvHtml | Should -Match 'function renderTopology\(\)'
        $script:EnvHtml | Should -Match 'function renderGovernance\(\)'
        $script:EnvHtml | Should -Match 'function buildMgTree\(mgs\)'
        $script:EnvHtml | Should -Match 'truncateMiddle = function\('
        $script:EnvHtml | Should -Match 'enforcementBadge = function\('
        $script:EnvHtml | Should -Match 'function openPanel\('
    }

    It 'Topology/Governance tabs are only pushed when hasNetworkingData/hasGovernanceData are true (AB#376/380 graceful hide)' {
        $script:EnvHtml | Should -Match "if\(hasNetworkingData\)\{\s*tabs\.push\(\['topology'"
        $script:EnvHtml | Should -Match "if\(hasGovernanceData\)\{\s*tabs\.push\(\['governance'"
    }

    It 'the Firewall drill-down honestly notes that rule-collection data is not collected (AB#390 graceful degrade)' {
        $script:EnvHtml | Should -Match 'Rule-collection data \(Microsoft\.Network/firewallPolicies\) is not collected'
    }

    It 'the VNet topology honestly notes its peering/VM-grouping are aggregates, not fabricated precise edges (AB#376)' {
        $script:EnvHtml | Should -Match 'Peered VNet identities are not collected'
        $script:EnvHtml | Should -Match 'VM-to-subnet mapping'
    }

    It 'the topology viewport wires reset/fit/pan/zoom controls (AB#378)' {
        $script:EnvHtml | Should -Match 'id="topo-reset"'
        $script:EnvHtml | Should -Match 'id="topo-fit"'
        $script:EnvHtml | Should -Match "addEventListener\('wheel'"
        $script:EnvHtml | Should -Match "addEventListener\('mousedown'"
    }

    It 'Manual review and Governance sub-tables each get their own search input (AB#386)' {
        $script:EnvHtml | Should -Match 'id="manual-search"'
        $script:EnvHtml | Should -Match 'id="gov-policy-search"'
        $script:EnvHtml | Should -Match 'id="gov-budgets-search"'
        $script:EnvHtml | Should -Match 'id="gov-locks-search"'
    }

    It 'Findings/Gaps/Manual/Governance rows are marked clickable for the side panel (AB#387)' {
        $script:EnvHtml | Should -Match 'class="clickable-row"'
        ([regex]::Matches($script:EnvHtml, 'clickable-row')).Count | Should -BeGreaterThan 3
    }

    It 'the side panel and overlay markup exist exactly once (AB#377/387)' {
        ([regex]::Matches($script:EnvHtml, 'id="side-panel"')).Count | Should -Be 1
        ([regex]::Matches($script:EnvHtml, 'id="panel-overlay"')).Count | Should -Be 1
    }

    It 'stays a single self-contained offline file with the new sections added' {
        $script:EnvHtml | Should -Not -Match 'src\s*=\s*"http'
        $script:EnvHtml | Should -Not -Match 'href\s*=\s*"http'
        $script:EnvHtml | Should -Not -Match 'cdn\.'
        $script:EnvHtml | Should -Not -Match '<script[^>]+src='
    }
}

Describe 'Export-React — environment visuals degrade gracefully when uncollected (AB#376/380 hide-not-error)' {
    It 'does not throw and embeds null Networking/Governance/Tags when Collect never carried them' {
        $bareCollect = [pscustomobject]@{
            _meta = [pscustomobject]@{ scope = 'EntraOnly'; managementGroupId = $null; generatedOn = (Get-Date).ToString('o') }
        }
        { $script:BareReportPath = Export-React -Findings $script:Scored -Collect $bareCollect -OutputPath $script:OutDir } | Should -Not -Throw
        $bareHtml = Get-Content $script:BareReportPath -Raw
        $match = [regex]::Match($bareHtml, 'window\.__SCOUT_DATA__ = (\{.*?\});(?=\s*</script>)', [System.Text.RegularExpressions.RegexOptions]::Singleline)
        $embedded = $match.Groups[1].Value | ConvertFrom-Json -Depth 100
        $embedded.Networking.VirtualNetworks | Should -BeNullOrEmpty
        $embedded.Governance.ManagementGroups | Should -BeNullOrEmpty
        $embedded.Tags | Should -BeNullOrEmpty
    }

    It 'does not throw when Collect is $null (CollectOnly-style caller with nothing to embed yet)' {
        { Export-React -Findings $script:Scored -Collect $null -OutputPath $script:OutDir } | Should -Not -Throw
    }
}