tests/CollectorCollapse.Tests.ps1

#Requires -Version 7.0
#Requires -Modules Pester

<#
    AB#5543 — collapsing the duplicate inventory/assessment collection passes.

    Inventory and assessment used to each run their own Azure Resource Graph pass over the same
    resource types. `Invoke-Collect -FromInventory` now shapes the assessment's scalars from the
    rows an inventory pass already fetched.

    These tests pin the two things that make that safe:
      1. every projection reproduces the KQL semantics exactly (including the null-vs-zero and
         null-vs-false cases that a naive PowerShell rewrite silently gets wrong), and
      2. supplying -FromInventory does not re-query Azure, except for the one query that
         genuinely cannot be served from inventory rows.

    Pure-logic tests — no Azure connection.
#>


BeforeAll {
    $root = Split-Path $PSScriptRoot -Parent
    . "$root/src/collect/ConvertFrom-ScoutInventory.ps1"

    function New-Resource {
        param([string] $Type, [string] $Name = 'r1', [hashtable] $Properties = @{}, [hashtable] $Extra = @{})
        $obj = [ordered]@{
            id             = "/subscriptions/sub1/resourceGroups/rg1/providers/$Type/$Name"
            name           = $Name
            type           = $Type
            location       = 'eastus'
            resourceGroup  = 'rg1'
            subscriptionId = 'sub1'
            properties     = [pscustomobject] $Properties
        }
        foreach ($k in $Extra.Keys) { $obj[$k] = $Extra[$k] }
        return [pscustomobject] $obj
    }
}

Describe 'ConvertFrom-ScoutInventory — KQL semantics are preserved' {

    It 'reports peeringCount as null when the peerings array is absent (KQL array_length(null) is null, not 0)' {
        $shaped = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.network/virtualnetworks' -Properties @{}
        ) -ResourceContainers @()
        $shaped['virtualNetworks'][0].peeringCount | Should -BeNullOrEmpty
    }

    It 'counts peerings when present' {
        $shaped = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.network/virtualnetworks' -Properties @{
                virtualNetworkPeerings = @(1, 2, 3)
            }
        ) -ResourceContainers @()
        $shaped['virtualNetworks'][0].peeringCount | Should -Be 3
    }

    It 'leaves a missing bool as null rather than false (KQL tobool(null) is null)' {
        $shaped = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.keyvault/vaults' -Properties @{}
        ) -ResourceContainers @()
        $shaped['keyVaults'][0].softDelete | Should -BeNullOrEmpty
        $shaped['keyVaults'][0].purgeProtection | Should -BeNullOrEmpty
    }

    It 'preserves an explicit false' {
        $shaped = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.keyvault/vaults' -Properties @{ enableSoftDelete = $false }
        ) -ResourceContainers @()
        $shaped['keyVaults'][0].softDelete | Should -BeExactly $false
    }

    It 'computes subnet utilisation as used / (2^(32-prefix) - 5)' {
        # /24 => 256 - 5 = 251 usable; 2 IP configs => 0.8%
        $shaped = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.network/virtualnetworks' -Properties @{
                subnets = @(
                    [pscustomobject]@{
                        name       = 'snet1'
                        properties = [pscustomobject]@{
                            addressPrefix    = '10.0.0.0/24'
                            ipConfigurations = @(1, 2)
                        }
                    }
                )
            }
        ) -ResourceContainers @()
        $shaped['subnets'][0].total | Should -Be 251
        $shaped['subnets'][0].used | Should -Be 2
        $shaped['subnets'][0].ipUtilizationPct | Should -Be 0.8
    }

    It 'keeps only Allow + Inbound NSG rules from an internet-equivalent source' {
        $shaped = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.network/networksecuritygroups' -Properties @{
                securityRules = @(
                    [pscustomobject]@{ name = 'open'; properties = [pscustomobject]@{ access = 'Allow'; direction = 'Inbound'; sourceAddressPrefix = '*'; destinationPortRange = '22' } }
                    [pscustomobject]@{ name = 'deny'; properties = [pscustomobject]@{ access = 'Deny'; direction = 'Inbound'; sourceAddressPrefix = '*'; destinationPortRange = '80' } }
                    [pscustomobject]@{ name = 'out'; properties = [pscustomobject]@{ access = 'Allow'; direction = 'Outbound'; sourceAddressPrefix = '*'; destinationPortRange = '443' } }
                    [pscustomobject]@{ name = 'scoped'; properties = [pscustomobject]@{ access = 'Allow'; direction = 'Inbound'; sourceAddressPrefix = '10.0.0.0/8'; destinationPortRange = '3389' } }
                )
            }
        ) -ResourceContainers @()
        $shaped['nsgPublicInbound'].Count | Should -Be 1
        $shaped['nsgPublicInbound'][0].rule | Should -Be 'open'
        $shaped['nsgPublicInbound'][0].port | Should -Be '22'
    }

    It 'splits a private endpoint target id into provider and type, falling back to the manual connection' {
        $shaped = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.network/privateendpoints' -Properties @{
                manualPrivateLinkServiceConnections = @(
                    [pscustomobject]@{ properties = [pscustomobject]@{ privateLinkServiceId = '/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa1' } }
                )
            }
        ) -ResourceContainers @()
        $shaped['privateEndpoints'][0].targetProvider | Should -Be 'microsoft.storage'
        $shaped['privateEndpoints'][0].targetType | Should -Be 'storageaccounts'
    }

    It 'marks allPoolsZoned only when every AKS pool has zones' {
        $mixed = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.containerservice/managedclusters' -Properties @{
                agentPoolProfiles = @(
                    [pscustomobject]@{ availabilityZones = @('1', '2') }
                    [pscustomobject]@{ availabilityZones = $null }
                )
            }
        ) -ResourceContainers @()
        $mixed['aksClusters'][0].allPoolsZoned | Should -BeExactly $false

        $allZoned = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.containerservice/managedclusters' -Properties @{
                agentPoolProfiles = @([pscustomobject]@{ availabilityZones = @('1') })
            }
        ) -ResourceContainers @()
        $allZoned['aksClusters'][0].allPoolsZoned | Should -BeExactly $true
    }

    It 'derives zoneRedundant from the zones column and zoneEligible from the region list' {
        $shaped = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.compute/virtualmachines' -Name 'vm1' -Properties @{ hardwareProfile = [pscustomobject]@{ vmSize = 'Standard_D2s_v5' } } -Extra @{ zones = @('1') }
            New-Resource -Type 'microsoft.compute/virtualmachines' -Name 'vm2' -Properties @{} -Extra @{ zones = $null; location = 'nowhereland' }
        ) -ResourceContainers @()
        ($shaped['virtualMachines'] | Where-Object name -EQ 'vm1').zoneRedundant | Should -BeExactly $true
        ($shaped['virtualMachines'] | Where-Object name -EQ 'vm1').zoneEligible | Should -BeExactly $true
        ($shaped['virtualMachines'] | Where-Object name -EQ 'vm2').zoneRedundant | Should -BeExactly $false
        ($shaped['virtualMachines'] | Where-Object name -EQ 'vm2').zoneEligible | Should -BeExactly $false
    }

    It 'takes the parent policy name from the rule-collection-group resource id' {
        $group = New-Resource -Type 'microsoft.network/firewallpolicies/rulecollectiongroups' -Properties @{ priority = 100; ruleCollections = @() }
        $group.id = '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/firewallPolicies/pol1/ruleCollectionGroups/grp1'
        $shaped = ConvertFrom-ScoutInventory -Resources @($group) -ResourceContainers @()
        $shaped['firewallPolicyRuleGroups'][0].policyName | Should -Be 'pol1'
        $shaped['firewallPolicyRuleGroups'][0].priority | Should -Be 100
    }

    It 'strips the extension suffix to recover the Arc machine id' {
        $ext = New-Resource -Type 'microsoft.hybridcompute/machines/extensions' -Properties @{ type = 'MDE.Windows' }
        $ext.id = '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.HybridCompute/machines/m1/extensions/MDE'
        $shaped = ConvertFrom-ScoutInventory -Resources @($ext) -ResourceContainers @()
        $shaped['arcExtensions'][0].machineId | Should -Be '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.HybridCompute/machines/m1'
    }

    It 'de-duplicates rows that appear in both the resources and networkresources tables' {
        $vnet = New-Resource -Type 'microsoft.network/virtualnetworks' -Properties @{ virtualNetworkPeerings = @(1) }
        $shaped = ConvertFrom-ScoutInventory -Resources @($vnet, $vnet) -ResourceContainers @()
        $shaped['virtualNetworks'].Count | Should -Be 1
    }

    It 'projects subscriptions and resource groups out of the container rows' {
        $shaped = ConvertFrom-ScoutInventory -Resources @() -ResourceContainers @(
            [pscustomobject]@{ type = 'microsoft.resources/subscriptions'; name = 'Sub One'; subscriptionId = 'sub1'; properties = [pscustomobject]@{ state = 'Enabled' }; tags = $null }
            [pscustomobject]@{ type = 'microsoft.resources/subscriptions/resourcegroups'; name = 'rg1'; subscriptionId = 'sub1' }
        )
        $shaped['subscriptions'].Count | Should -Be 1
        $shaped['subscriptions'][0].id | Should -Be 'sub1'
        $shaped['subscriptions'][0].state | Should -Be 'Enabled'
        $shaped['deployments'].Count | Should -Be 1
        $shaped['deployments'][0].name | Should -Be 'rg1'
    }

    It 'summarises diagnostic coverage per resource type' {
        $shaped = ConvertFrom-ScoutInventory -Resources @(
            New-Resource -Type 'microsoft.storage/storageaccounts' -Name 'a' -Properties @{ diagnosticSettings = [pscustomobject]@{ x = 1 } }
            New-Resource -Type 'microsoft.storage/storageaccounts' -Name 'b' -Properties @{}
        ) -ResourceContainers @()
        $row = $shaped['diagnosticCoverage'] | Where-Object type -EQ 'microsoft.storage/storageaccounts'
        $row.total | Should -Be 2
        $row.withDiag | Should -Be 1
        $row.coveragePct | Should -Be 50
    }

    It 'never produces sqlDefenderPricing — that query reads SecurityResources, which inventory does not collect' {
        $shaped = ConvertFrom-ScoutInventory -Resources @() -ResourceContainers @()
        $shaped.ContainsKey('sqlDefenderPricing') | Should -BeFalse
    }
}

Describe 'Invoke-Collect -FromInventory — collects once, not twice' {

    BeforeAll {
        $root = Split-Path $PSScriptRoot -Parent
        # Stub the Az.ResourceGraph surface so the collector runs with no Azure connection and we
        # can count exactly which queries still reach Resource Graph.
        function Import-Module { param([Parameter(ValueFromRemainingArguments)] $Rest) }
        . "$root/src/collect/Invoke-Collect.ps1"
    }

    It 'sends only the query that cannot be served from inventory to Resource Graph' {
        $script:argQueries = @()
        function Search-AzGraph {
            param(
                [string] $Query, [int] $First, [int] $Skip,
                [string] $ManagementGroup, [string[]] $Subscription,
                [string] $ErrorAction
            )
            $script:argQueries += $Query
            return @()
        }

        $inventory = [pscustomobject]@{
            Resources          = @(
                [pscustomobject]@{
                    id = '/subscriptions/sub1/resourceGroups/rg1/providers/microsoft.keyvault/vaults/kv1'
                    name = 'kv1'; type = 'microsoft.keyvault/vaults'; location = 'eastus'
                    resourceGroup = 'rg1'; subscriptionId = 'sub1'
                    properties = [pscustomobject]@{ enableSoftDelete = $true; enablePurgeProtection = $true }
                }
            )
            ResourceContainers = @(
                [pscustomobject]@{ type = 'microsoft.resources/subscriptions'; name = 'Sub One'; subscriptionId = 'sub1'; properties = [pscustomobject]@{ state = 'Enabled' }; tags = $null }
            )
        }

        $collect = Invoke-Collect -FromInventory $inventory

        # Exactly one query type still goes to Azure: the SecurityResources pricings lookup.
        $script:argQueries.Count | Should -Be 1
        $script:argQueries[0] | Should -Match 'SecurityResources'
        $script:argQueries[0] | Should -Match 'microsoft.security/pricings'

        # And the assessment data really was populated from the inventory rows.
        $collect.domains.security.keyVaults.Count | Should -Be 1
        $collect.domains.security.keyVaults[0].softDelete | Should -BeExactly $true
        $collect.subscriptions.Count | Should -Be 1
        $collect.subscriptions[0].id | Should -Be 'sub1'
    }

    It 'still queries Resource Graph for every query when no inventory is supplied' {
        $script:argQueries = @()
        function Search-AzGraph {
            param(
                [string] $Query, [int] $First, [int] $Skip,
                [string] $ManagementGroup, [string[]] $Subscription,
                [string] $ErrorAction
            )
            $script:argQueries += $Query
            return @()
        }

        Invoke-Collect | Out-Null
        # The assessment-only path is unchanged: every typed query still runs.
        $script:argQueries.Count | Should -BeGreaterThan 30
    }
}