tests/Report.Pdf.Tests.ps1

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

<#
    Pester tests for src/report/renderers/Export-Pdf.ps1 -- the hand-rolled,
    dependency-free .pdf renderer (ADO Stories AB#379/394/395). No Azure
    connection, no external library, no network access required -- exactly
    what the renderer itself needs to run.
#>


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

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

    # 45 findings in one area -- comfortably forces the findings table onto
    # multiple pages so the AB#394 header-repeat path actually exercises.
    $script:ManyFindings = 1..45 | ForEach-Object {
        $status = @('Pass', 'Fail', 'Partial')[$_ % 3]
        New-PdfTestFinding "CAF-NET-$($_.ToString('00'))" 'CAF' 'Networking' $status 'medium'
    }
    $script:ManyFindings += @(
        (New-PdfTestFinding 'WAF-SEC-01' 'WAF' 'Security' 'Fail' 'high')
        (New-PdfTestFinding 'WAF-SEC-02' 'WAF' 'Security' 'Manual')
    )
    $script:Scored = Get-Score -Findings $script:ManyFindings

    $script:Collect = [pscustomobject]@{
        _meta = [pscustomobject]@{ scope = 'ArmOnly'; managementGroupId = 'mg-test-01' }
    }

    $script:OutDir = Join-Path $script:Root 'tests' 'test-output' 'pdf'
    if (Test-Path $script:OutDir) { Remove-Item $script:OutDir -Recurse -Force }

    # Reads the whole PDF as Latin-1 (ISO-8859-1) text -- a byte-for-byte
    # lossless mapping of 0..255 to chars, so binary segments (the %-marker
    # comment, an embedded JPEG) can sit alongside ASCII structure/content
    # without throwing, while still letting Select-String/regex search for
    # the ASCII content-stream text this renderer writes.
    function Get-PdfText {
        param([string]$Path)
        $bytes = [System.IO.File]::ReadAllBytes($Path)
        return [System.Text.Encoding]::GetEncoding('ISO-8859-1').GetString($bytes)
    }
}

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

Describe 'Export-Pdf -- basic document structure AB#379/394/395' {
    BeforeAll {
        $script:PdfPath = Export-Pdf -Findings $script:Scored -Collect $script:Collect -OutputPath $script:OutDir
        $script:Text = Get-PdfText -Path $script:PdfPath
    }

    It 'writes assessment_report.pdf into -OutputPath and returns its path' {
        $script:PdfPath | Should -Exist
        (Split-Path $script:PdfPath -Leaf) | Should -Be 'assessment_report.pdf'
    }

    It 'starts with the %PDF header and ends with %%EOF' {
        $script:Text.Substring(0, 8) | Should -Be '%PDF-1.4'
        $script:Text.TrimEnd().Substring($script:Text.TrimEnd().Length - 5) | Should -Be '%%EOF'
    }

    It 'declares a Catalog, Pages tree, and xref/trailer' {
        $script:Text | Should -Match '/Type /Catalog'
        $script:Text | Should -Match '/Type /Pages'
        $script:Text | Should -Match 'trailer'
        $script:Text | Should -Match 'startxref'
    }

    It 'renders more than one page (the findings table paginates)' {
        ([regex]::Matches($script:Text, '/Type /Page[^s]')).Count | Should -BeGreaterThan 1
    }

    It 'repeats the findings-table column header across a page break (AB#394)' {
        ([regex]::Matches($script:Text, '\(ID\) Tj')).Count | Should -BeGreaterThan 1
        ([regex]::Matches($script:Text, '\(Severity\) Tj')).Count | Should -BeGreaterThan 1
    }

    It 'renders every expected sub-section heading (AB#395)' {
        foreach ($heading in 'Framework Alignment', 'Architecture Diagram', 'Executive Summary', 'Findings by Area', 'Prioritized Gaps', 'Manual Review Items') {
            $pattern = [regex]::Escape("($heading) Tj")
            $script:Text | Should -Match $pattern
        }
    }

    It 'draws status/severity dots as vector circles, not text glyphs (AB#395 emoji stand-in)' {
        # Every Add-ScoutPdfDot call emits four "c" (Bezier curve) operators.
        ([regex]::Matches($script:Text, ' c\r?\n')).Count | Should -BeGreaterThan 0
    }

    It 'notes the missing architecture diagram honestly rather than embedding nothing silently' {
        $script:Text | Should -Match 'Architecture diagram not embedded'
        $script:Text | Should -Not -Match '/DCTDecode'
    }

    It 'is deterministic -- identical input renders byte-identical output' {
        $repeatDir = Join-Path $script:Root 'tests' 'test-output' 'pdf-repeat'
        if (Test-Path $repeatDir) { Remove-Item $repeatDir -Recurse -Force }
        try {
            $repeatPath = Export-Pdf -Findings $script:Scored -Collect $script:Collect -OutputPath $repeatDir
            $a = [System.IO.File]::ReadAllBytes($script:PdfPath)
            $b = [System.IO.File]::ReadAllBytes($repeatPath)
            [System.Convert]::ToBase64String($a) | Should -Be ([System.Convert]::ToBase64String($b))
        }
        finally {
            if (Test-Path $repeatDir) { Remove-Item $repeatDir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }
}

Describe 'Export-Pdf -- culture safety' {
    It 'never emits a comma decimal separator in content-stream numbers, even on a comma-decimal thread culture' {
        $dir = Join-Path $script:Root 'tests' 'test-output' 'pdf-culture'
        if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        $originalCulture = [System.Threading.Thread]::CurrentThread.CurrentCulture
        try {
            [System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::GetCultureInfo('de-DE')
            $path = Export-Pdf -Findings $script:Scored -Collect $script:Collect -OutputPath $dir
            $text = Get-PdfText -Path $path
            # A margin-based coordinate (54.00) must appear with a period; no
            # "<digit>,<digit>" pattern should ever appear inside a Td/re/m/l/c op line.
            $text | Should -Match '54\.00'
            ($text -split "`n" | Where-Object { $_ -match '\d,\d{2} ' -and $_ -notmatch '^\d+ \d+ obj' }) | Should -BeNullOrEmpty
        }
        finally {
            [System.Threading.Thread]::CurrentThread.CurrentCulture = $originalCulture
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }
}

Describe 'Export-Pdf -- AB#379 diagram embed' {
    It 'embeds a valid baseline JPEG dropped at OutputPath/diagram.jpg' {
        # NOTE: the test name deliberately avoids angle brackets around
        # "OutputPath" -- Pester v5 treats a literal <Name> token inside an
        # It name as a data-driven-test placeholder (for -TestCases) and
        # tries to resolve it as a variable even with no -TestCases supplied,
        # throwing "variable ... has not been set" at discovery time.
        # -Skip: is a discovery-time value in Pester v5 -- it cannot depend on a
        # BeforeAll-computed variable (BeforeAll runs during the later Run
        # phase, so the condition would always see an unset value at
        # discovery). Checking availability inside the It body with
        # Set-ItResult -Skipped is the safe, well-documented alternative.
        try {
            Add-Type -AssemblyName System.Drawing.Common -ErrorAction Stop
        }
        catch {
            Set-ItResult -Skipped -Because 'System.Drawing.Common is unavailable on this platform'
            return
        }

        $dir = Join-Path $script:Root 'tests' 'test-output' 'pdf-diagram'
        if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        try {
            $bmp = [System.Drawing.Bitmap]::new(80, 40)
            $g = [System.Drawing.Graphics]::FromImage($bmp)
            $g.Clear([System.Drawing.Color]::CornflowerBlue)
            $bmp.Save((Join-Path $dir 'diagram.jpg'), [System.Drawing.Imaging.ImageFormat]::Jpeg)
            $g.Dispose(); $bmp.Dispose()

            $path = Export-Pdf -Findings $script:Scored -Collect $script:Collect -OutputPath $dir
            $text = Get-PdfText -Path $path
            $text | Should -Match '/Filter /DCTDecode'
            $text | Should -Match '/Subtype /Image'
            $text | Should -Match '/Width 80'
            $text | Should -Match '/Height 40'
            $text | Should -Not -Match 'Architecture diagram not embedded'
        }
        finally {
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }

    It 'skips a malformed diagram.jpg (not really a JPEG) without failing the whole render' {
        $dir = Join-Path $script:Root 'tests' 'test-output' 'pdf-bad-diagram'
        if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        try {
            'this is not a jpeg' | Out-File (Join-Path $dir 'diagram.jpg') -Encoding ascii
            # Redirect the warning stream (3) into the success stream so both the
            # returned path (a [string]) and any [WarningRecord]s come back on one
            # pipeline -- more robust than -WarningVariable against a plain (non
            # -CmdletBinding) function, which every renderer in this folder is.
            $allOutput = @(Export-Pdf -Findings $script:Scored -Collect $script:Collect -OutputPath $dir 3>&1)
            $path = $allOutput | Where-Object { $_ -is [string] } | Select-Object -First 1
            $warnings = $allOutput | Where-Object { $_ -is [System.Management.Automation.WarningRecord] }
            $path | Should -Exist
            $text = Get-PdfText -Path $path
            $text | Should -Not -Match '/DCTDecode'
            @($warnings) | Where-Object { $_.Message -match 'does not look like a valid JPEG' } | Should -Not -BeNullOrEmpty
        }
        finally {
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }
}

Describe 'Get-ScoutPdfJpegInfo (unit)' {
    It 'returns $null for non-JPEG bytes' {
        Get-ScoutPdfJpegInfo -Bytes ([byte[]]@(1, 2, 3, 4, 5)) | Should -BeNullOrEmpty
    }

    It 'parses width/height/components out of a real baseline JPEG' -Skip:(-not (Get-Command -Name 'Add-Type' -ErrorAction SilentlyContinue)) {
        try {
            Add-Type -AssemblyName System.Drawing.Common -ErrorAction Stop
        }
        catch {
            Set-ItResult -Skipped -Because 'System.Drawing.Common is unavailable on this platform'
            return
        }
        $bmp = [System.Drawing.Bitmap]::new(64, 32)
        $g = [System.Drawing.Graphics]::FromImage($bmp)
        $g.Clear([System.Drawing.Color]::Red)
        $ms = [System.IO.MemoryStream]::new()
        $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Jpeg)
        $g.Dispose(); $bmp.Dispose()

        $info = Get-ScoutPdfJpegInfo -Bytes $ms.ToArray()
        $info.Width | Should -Be 64
        $info.Height | Should -Be 32
        $info.Components | Should -BeIn @(1, 3)
    }
}

Describe 'Export-Pdf -- edge cases' {
    It 'does not throw on an empty Findings set and still produces a valid PDF' {
        $dir = Join-Path $script:Root 'tests' 'test-output' 'pdf-empty'
        if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        try {
            $emptyScored = Get-Score -Findings @()
            $path = Export-Pdf -Findings $emptyScored -Collect $null -OutputPath $dir
            $path | Should -Exist
            (Get-PdfText -Path $path) | Should -Match '%PDF-1.4'
        }
        finally {
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }
}

Describe 'Export-ScoutPdfHtmlFallback (unit)' {
    It 'writes a clearly-labeled fallback HTML file, not a silently-renamed non-PDF' {
        $dir = Join-Path $script:Root 'tests' 'test-output' 'pdf-fallback'
        if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        try {
            $path = Export-ScoutPdfHtmlFallback -Findings $script:Scored -Collect $script:Collect -OutputPath $dir -Reason 'synthetic test failure'
            (Split-Path $path -Leaf) | Should -Be 'assessment_report_fallback.html'
            $content = Get-Content $path -Raw
            $content | Should -Match 'PDF generation failed'
            $content | Should -Match 'synthetic test failure'
            $content | Should -Match '__FINDINGS__'
        }
        finally {
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }
}