tests/Assessment.Governance.Tests.ps1

#Requires -Version 7.0
#Requires -Modules Pester
#Requires -Modules Az.ResourceGraph

<#
    Pester tests for the native governance collector (src/ingest/Import-Governance.ps1,
    ADO AB#5041) — the replacement for the AzGovViz hard dependency.

    Two layers are covered:
      1. Import-Governance shapes collect.json's `governance` object from mocked
         Resource Graph (MGs / policy / role assignments) + mocked ARM REST
         (budgets / locks) — no AzGovViz, no live Azure.
      2. The REAL governance rule files + Compare-Benchmark score against that
         shape, proving the benchmark and CAF governance/identity/billing rules
         actually resolve (instead of degrading to Unknown) once native
         governance data is present.

    Search-AzGraph and Invoke-AzRestMethod are mocked throughout.
#>


BeforeAll {
    $root = Split-Path $PSScriptRoot -Parent
    Import-Module Az.ResourceGraph -ErrorAction Stop
    Import-Module powershell-yaml -ErrorAction Stop
    . "$root/src/ingest/Import-Governance.ps1"
    . "$root/src/assess/Compare-Benchmark.ps1"
    . "$root/src/assess/Invoke-Assessment.ps1"
    . "$root/src/assess/engine/Get-RuleSet.ps1"
    . "$root/src/assess/engine/Resolve-JsonPath.ps1"
    . "$root/src/assess/engine/Invoke-Rule.ps1"

    # Stub Invoke-AzRestMethod so Import-Governance's Get-Command probe finds it
    # (skipping the Az.Accounts import) and Pester can mock it below.
    if (-not (Get-Command Invoke-AzRestMethod -ErrorAction SilentlyContinue)) {
        function Invoke-AzRestMethod { param([string] $Method, [string] $Path) }
    }

    # Deterministic synthetic governance dataset used across the collector +
    # rule-scoring tests. Six policy assignments (all Default enforcement, all
    # carrying parameters), two management groups, three User role assignments,
    # two budgets, one lock — chosen so each governance rule has a predictable outcome.
    function Get-MockCollect {
        [pscustomobject]@{
            subscriptions = @(
                [pscustomobject]@{ id = '00000000-0000-0000-0000-000000000001'; name = 'sub-a' }
                [pscustomobject]@{ id = '00000000-0000-0000-0000-000000000002'; name = 'sub-b' }
            )
        }
    }

    $script:MockPolicy = 1..6 | ForEach-Object {
        [pscustomobject]@{
            name = "assignment-$_"; id = "/pa/$_"; type = 'microsoft.authorization/policyassignments'
            properties = [pscustomobject]@{
                displayName = "ALZ-Policy-$_"; enforcementMode = 'Default'
                parameters  = [pscustomobject]@{ effect = [pscustomobject]@{ value = 'DeployIfNotExists' } }
            }
        }
    }
    $script:MockMgs = @(
        [pscustomobject]@{ name = 'platform';     id = '/providers/Microsoft.Management/managementGroups/platform';     displayName = 'Platform';      parent = 'tenant-root' }
        [pscustomobject]@{ name = 'landingzones'; id = '/providers/Microsoft.Management/managementGroups/landingzones'; displayName = 'Landing Zones'; parent = 'tenant-root' }
    )
    $script:MockRoles = 1..3 | ForEach-Object {
        [pscustomobject]@{ name = "ra-$_"; id = "/ra/$_"; type = 'microsoft.authorization/roleassignments'
            properties = [pscustomobject]@{ principalType = 'User'; roleDefinitionId = '/rd/reader' } }
    }
}

Describe 'Import-Governance — native collector shape' {
    BeforeEach {
        Mock Search-AzGraph {
            if ($Query -match 'managementgroups')   { return $script:MockMgs }
            if ($Query -match 'policyassignments')  { return $script:MockPolicy }
            if ($Query -match 'roleassignments')    { return $script:MockRoles }
            return @()
        }
        Mock Invoke-AzRestMethod {
            if ($Path -match 'Microsoft\.Consumption/budgets') {
                return [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(@{ name = 'budget-a' }, @{ name = 'budget-b' }) } | ConvertTo-Json -Depth 5) }
            }
            if ($Path -match 'Microsoft\.Authorization/locks') {
                return [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(@{ name = 'lock-a'; properties = @{ level = 'CanNotDelete' } }) } | ConvertTo-Json -Depth 5) }
            }
            return [pscustomobject]@{ StatusCode = 404; Content = '{}' }
        }
    }

    It 'populates every governance dataset the rules query, with no AzGovViz call' {
        $result = Import-Governance -Collect (Get-MockCollect)

        @($result.governance.managementGroups).Count  | Should -Be 2
        @($result.governance.policyAssignments).Count | Should -Be 6
        @($result.governance.roleAssignments).Count   | Should -Be 3
        # budgets and locks aggregate across BOTH subscriptions (2 subs × 2 budgets,
        # 2 subs × 1 lock) — proves the per-subscription ARM REST fan-in.
        @($result.governance.budgets).Count           | Should -Be 4
        @($result.governance.resourceLocks).Count     | Should -Be 2
        @($result.governance.classicAdministrators).Count | Should -Be 0
        @($result.governance.pimEligibility).Count        | Should -Be 0
    }

    It 'scopes Resource Graph to the management group when one is supplied' {
        Import-Governance -Collect (Get-MockCollect) -ManagementGroupId 'contoso-root' | Out-Null
        Should -Invoke Search-AzGraph -ParameterFilter { $ManagementGroup -eq 'contoso-root' }
        Should -Invoke Search-AzGraph -ParameterFilter { $null -eq $ManagementGroup } -Times 0 -Exactly
    }

    It 'reads budgets and locks once per subscription via ARM REST' {
        Import-Governance -Collect (Get-MockCollect) | Out-Null
        Should -Invoke Invoke-AzRestMethod -ParameterFilter { $Path -match 'budgets' } -Times 2 -Exactly
        Should -Invoke Invoke-AzRestMethod -ParameterFilter { $Path -match 'locks' }   -Times 2 -Exactly
    }

    It 'never invokes the AzGovViz collector' {
        # Import-Governance must be fully self-contained — prove it does not shell out
        # to git (the AzGovViz clone) as a proxy for "no visualizer dependency".
        Mock git { throw 'git must not be called by the native collector' }
        { Import-Governance -Collect (Get-MockCollect) } | Should -Not -Throw
    }
}

Describe 'Governance rules score against native collect (unblocks AB#5041)' {
    BeforeEach {
        Mock Search-AzGraph {
            if ($Query -match 'managementgroups')   { return $script:MockMgs }
            if ($Query -match 'policyassignments')  { return $script:MockPolicy }
            if ($Query -match 'roleassignments')    { return $script:MockRoles }
            return @()
        }
        Mock Invoke-AzRestMethod {
            if ($Path -match 'budgets') { return [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(@{ name = 'b1' }, @{ name = 'b2' }) } | ConvertTo-Json) } }
            if ($Path -match 'locks')   { return [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(@{ name = 'l1' }) } | ConvertTo-Json) } }
            return [pscustomobject]@{ StatusCode = 404; Content = '{}' }
        }
    }

    It 'resolves CAF governance/identity/resource-org/billing rules to real Pass/Fail (not Unknown)' {
        $collect  = Import-Governance -Collect (Get-MockCollect)
        $ruleSet  = Get-RuleSet -Patterns @('caf.governance', 'caf.identity', 'caf.resourceorg', 'caf.billing')
        $findings = Invoke-Assessment -Collect $collect -RuleSet $ruleSet -Assessment 'GovTest'
        $byId     = @{}; $findings | ForEach-Object { $byId[$_.Id] = $_.Status }

        $byId['CAF-GOV-01'] | Should -Be 'Pass'   # policy assignments present
        $byId['CAF-GOV-02'] | Should -Be 'Pass'   # none DoNotEnforce
        $byId['CAF-GOV-03'] | Should -Be 'Pass'   # a resource lock exists
        $byId['CAF-GOV-04'] | Should -Be 'Pass'   # >5 assignments
        $byId['CAF-GOV-05'] | Should -Be 'Pass'   # assignments carry parameters
        $byId['CAF-RES-02'] | Should -Be 'Pass'   # >1 management group
        $byId['CAF-IDN-01'] | Should -Be 'Pass'   # <50 user role assignments
        $byId['CAF-IDN-03'] | Should -Be 'Pass'   # no classic admins
        $byId['CAF-BIL-01'] | Should -Be 'Pass'   # budgets present
        $byId['CAF-BIL-05'] | Should -Be 'Pass'   # >1 budget
    }

    It 'flags a DoNotEnforce policy assignment as Fail on CAF-GOV-02' {
        Mock Search-AzGraph {
            if ($Query -match 'managementgroups')  { return $script:MockMgs }
            if ($Query -match 'policyassignments') {
                return @([pscustomobject]@{ name = 'lax'; properties = [pscustomobject]@{ displayName = 'Lax'; enforcementMode = 'DoNotEnforce'; parameters = $null } })
            }
            if ($Query -match 'roleassignments')   { return $script:MockRoles }
            return @()
        }
        $collect  = Import-Governance -Collect (Get-MockCollect)
        $ruleSet  = Get-RuleSet -Patterns @('caf.governance')
        $findings = Invoke-Assessment -Collect $collect -RuleSet $ruleSet -Assessment 'GovTest'
        ($findings | Where-Object Id -eq 'CAF-GOV-02').Status | Should -Be 'Fail'
    }
}

Describe 'Compare-Benchmark with native governance data' {
    It 'emits real MG/policy Pass-Fail findings when management groups are present' {
        $collect = [pscustomobject]@{
            governance = [pscustomobject]@{
                managementGroups  = @([pscustomobject]@{ name = 'platform' }, [pscustomobject]@{ name = 'landingzones' })
                policyAssignments = @([pscustomobject]@{ properties = [pscustomobject]@{ displayName = 'Deny-Public-IP' } })
            }
        }
        $benchmark = Get-Content "$(Split-Path $PSScriptRoot -Parent)/src/assess/benchmarks/alz-reference.json" -Raw | ConvertFrom-Json
        $findings  = Compare-Benchmark -Collect $collect -Benchmark $benchmark

        ($findings | Where-Object Id -eq 'BENCH-GOV-DATA') | Should -BeNullOrEmpty
        ($findings | Where-Object Id -eq 'BENCH-MG-platform').Status | Should -Be 'Pass'
        ($findings | Where-Object Id -eq 'BENCH-POL-Deny-Public-IP').Status | Should -Be 'Pass'
    }

    It 'returns the explicit Unknown guard when governance data is absent' {
        $collect   = [pscustomobject]@{ governance = [pscustomobject]@{ managementGroups = @() } }
        $benchmark = Get-Content "$(Split-Path $PSScriptRoot -Parent)/src/assess/benchmarks/alz-reference.json" -Raw | ConvertFrom-Json
        $findings  = Compare-Benchmark -Collect $collect -Benchmark $benchmark

        @($findings).Count | Should -Be 1
        $findings[0].Id     | Should -Be 'BENCH-GOV-DATA'
        $findings[0].Status | Should -Be 'Unknown'
    }
}