tests/Assessment.Engine.Tests.ps1

#Requires -Version 7.0
#Requires -Modules Pester

<#
    Smoke tests for the assessment engine (src/assess). Validates the scoring
    math and assert semantics that the audit flagged (AB#5087/#5088/#5089) plus
    the JSONPath null-path handling. Pure-logic tests — no Azure connection.
#>


BeforeAll {
    $root = Split-Path $PSScriptRoot -Parent
    . "$root/src/assess/engine/Resolve-JsonPath.ps1"
    . "$root/src/assess/engine/Invoke-Rule.ps1"
    . "$root/src/assess/engine/Get-Score.ps1"

    function New-Finding {
        param($Framework, $Area, $Status, $Severity = 'medium', $Weight = 1.0)
        [pscustomobject]@{
            Id = 'X'; Title = 't'; Framework = $Framework; Area = $Area; Severity = $Severity
            Status = $Status; EvidenceCount = 0; Evidence = @(); Remediation = 'r'; Manual = $false
            AreaWeight = $Weight
        }
    }
}

Describe 'Resolve-JsonPath' {
    It 'returns empty for a null/blank path (manual rules)' {
        (Resolve-JsonPath -InputObject ([pscustomobject]@{ a = 1 }) -Path '').Count | Should -Be 0
    }
    It 'matches a scalar filter without .length' {
        $obj = [pscustomobject]@{ networking = [pscustomobject]@{ virtualNetworks = @(
            [pscustomobject]@{ name = 'a'; peeringCount = 2 }, [pscustomobject]@{ name = 'b'; peeringCount = 0 }) } }
        (Resolve-JsonPath -InputObject $obj -Path '$.networking.virtualNetworks[?(@.peeringCount > 0)]').Count | Should -Be 1
    }
}

Describe 'Get-Score' {
    It 'excludes Manual and Unknown from the denominator but surfaces the counts' {
        $f = @(
            New-Finding CAF 'Net' 'Pass'
            New-Finding CAF 'Net' 'Fail'
            New-Finding CAF 'Net' 'Manual'
            New-Finding CAF 'Net' 'Unknown'
        )
        $s = Get-Score -Findings $f
        $area = $s.Areas | Where-Object Area -eq 'Net'
        $area.Score   | Should -Be 50      # 1 pass / 2 scorable
        $area.Manual  | Should -Be 1
        $area.Unknown | Should -Be 1
    }

    It 'weights the framework score by AreaWeight, not a flat mean' {
        $f = @(
            (New-Finding CAF 'Big'   'Fail' 'high' 3.0)   # area score 0, weight 3
            (New-Finding CAF 'Small' 'Pass' 'low'  1.0)   # area score 100, weight 1
        )
        $s = Get-Score -Findings $f
        # weighted: (0*3 + 100*1)/4 = 25, not the flat mean of 50
        ($s.Frameworks | Where-Object Framework -eq 'CAF').Score | Should -Be 25
    }

    It 'sorts unknown/missing severity LAST in the gap list' {
        $f = @(
            (New-Finding CAF 'A' 'Fail' 'bogus')
            (New-Finding CAF 'B' 'Fail' 'high')
        )
        $s = Get-Score -Findings $f
        $s.Gaps[0].Severity | Should -Be 'high'
    }
}

Describe 'Invoke-Rule' {
    It 'marks a rule Error (not Pass) when its query throws' {
        # NB: `.length` inside a JSONPath filter (the historical AB#5083 example)
        # does NOT throw under the Newtonsoft JSONPath engine that ships with
        # PowerShell 7 — it silently evaluates to no matches, which is exactly
        # the "collapses to a Pass on countEquals:0" failure mode AB#5083 warns
        # about, just via a different path (Resolve-JsonPath legitimately
        # returning zero results rather than throwing). Use a path that is
        # actually malformed (unterminated filter) to exercise the throw ->
        # Error contract this test targets.
        $rule = [pscustomobject]@{ id = 'E1'; title = 't'; severity = 'high'; manual = $false
            query = '$.bad[?(@.x.length'; assert = [pscustomobject]@{ type = 'countEquals'; value = 0 }; remediation = 'r' }
        $obj = [pscustomobject]@{ bad = @([pscustomobject]@{ x = @(1) }) }
        (Invoke-Rule -Rule $rule -Collect $obj -Area 'A' -Framework 'CAF').Status | Should -Be 'Error'
    }
}