tests/Collect.SubscriptionSecurityPolicySweep.Tests.ps1
|
#Requires -Version 7.0 #Requires -Modules Pester <# Focused, offline tests for the AB#5638 subscription security/policy collect phase. Every Azure command is stubbed; these tests never require credentials or network access. #> BeforeAll { $script:root = Split-Path $PSScriptRoot -Parent . "$script:root/src/collect/Get-ScoutSubscriptionSecurityPolicySweep.ps1" $script:subscriptions = @( [pscustomobject]@{ id = 'sub-1'; name = 'First subscription' } [pscustomobject]@{ id = 'sub-2'; name = 'Second subscription' } ) function Initialize-ScoutSweepStub { $script:currentSubscription = 'original-sub' $script:contextCalls = [System.Collections.Generic.List[string]]::new() $script:assessmentAttempts = @{} $script:sleepCalls = [System.Collections.Generic.List[int]]::new() $script:failPolicySubscription = $null $script:retryAssessmentSubscription = $null $script:unregisteredPricingSubscription = $null $script:failContextSubscription = $null function global:Get-AzContext { param($ErrorAction) $null = $ErrorAction [pscustomobject]@{ Subscription = [pscustomobject]@{ Id = 'original-sub' } } } function global:Set-AzContext { param($SubscriptionId, $ErrorAction) $null = $ErrorAction if ($SubscriptionId -eq $script:failContextSubscription) { throw "Cannot enter $SubscriptionId" } $script:currentSubscription = $SubscriptionId $script:contextCalls.Add($SubscriptionId) } function global:Get-AzSecurityAlert { param($ErrorAction) $null = $ErrorAction [pscustomobject]@{ Name = "alert-$script:currentSubscription" } } function global:Get-AzSecurityAssessment { param($ErrorAction) $null = $ErrorAction $subId = $script:currentSubscription if (-not $script:assessmentAttempts.ContainsKey($subId)) { $script:assessmentAttempts[$subId] = 0 } $script:assessmentAttempts[$subId]++ if ( $subId -eq $script:retryAssessmentSubscription -and $script:assessmentAttempts[$subId] -lt 3 ) { throw 'HTTP 500 InternalServerError' } [pscustomobject]@{ Name = "assessment-$subId" } } function global:Get-AzSecurityPricing { param($ErrorAction) $null = $ErrorAction if ($script:currentSubscription -eq $script:unregisteredPricingSubscription) { throw "Subscription is not registered to use namespace 'Microsoft.Security'" } [pscustomobject]@{ Name = "pricing-$script:currentSubscription" } } function global:Get-AzSecuritySecureScore { param($ErrorAction) $null = $ErrorAction [pscustomobject]@{ Name = "score-$script:currentSubscription" } } function global:Get-AzSecuritySecureScoreControl { param($ErrorAction) $null = $ErrorAction [pscustomobject]@{ Name = "control-$script:currentSubscription" } } function global:Get-AzDiagnosticSetting { param($ResourceId, $ErrorAction) $null = $ErrorAction [pscustomobject]@{ Name = "diagnostic-$script:currentSubscription"; ResourceId = $ResourceId } } function global:Get-AzPolicyState { param($SubscriptionId, $ErrorAction) $null = $ErrorAction if ($SubscriptionId -eq $script:failPolicySubscription) { throw 'Policy Insights access denied' } [pscustomobject]@{ PolicyAssignmentName = "policy-$SubscriptionId" } } function global:Start-Sleep { param([int] $Milliseconds) $script:sleepCalls.Add($Milliseconds) } } function Clear-ScoutSweepStub { foreach ($name in @( 'Get-AzContext', 'Set-AzContext', 'Get-AzSecurityAlert', 'Get-AzSecurityAssessment', 'Get-AzSecurityPricing', 'Get-AzSecuritySecureScore', 'Get-AzSecuritySecureScoreControl', 'Get-AzDiagnosticSetting', 'Get-AzPolicyState', 'Start-Sleep' )) { if (Test-Path "Function:\$name") { Remove-Item "Function:\$name" -Force -ErrorAction SilentlyContinue } } } } Describe 'Get-ScoutSubscriptionSecurityPolicySweep integration contract' { BeforeEach { Initialize-ScoutSweepStub } AfterEach { Clear-ScoutSweepStub } It 'returns one complete, typed envelope per subscription' { $results = @(Get-ScoutSubscriptionSecurityPolicySweep -Subscriptions $script:subscriptions) $results.Count | Should -Be 2 $results[0].type | Should -Be 'AZSC/Subscription/SecurityPolicySweep' $results[0].subscriptionId | Should -Be 'sub-1' $results[0].subscriptionName | Should -Be 'First subscription' $results[0].properties.DefenderAlerts[0].Name | Should -Be 'alert-sub-1' $results[0].properties.DefenderAssessments[0].Name | Should -Be 'assessment-sub-1' $results[0].properties.DefenderPricing[0].Name | Should -Be 'pricing-sub-1' $results[0].properties.DefenderSecureScores[0].Name | Should -Be 'score-sub-1' $results[0].properties.DefenderSecureScoreControls[0].Name | Should -Be 'control-sub-1' $results[0].properties.SubscriptionDiagnosticSettings[0].ResourceId | Should -Be '/subscriptions/sub-1' $results[0].properties.PolicyComplianceStates[0].PolicyAssignmentName | Should -Be 'policy-sub-1' @($results[0].properties.CollectionErrors).Count | Should -Be 0 @($results[0].properties.PSObject.Properties.Name) | Should -Be @( 'DefenderAlerts', 'DefenderAssessments', 'DefenderPricing', 'DefenderSecureScores', 'DefenderSecureScoreControls', 'SubscriptionDiagnosticSettings', 'PolicyComplianceStates', 'CollectionStatus', 'CollectionErrors' ) } It 'enters each subscription once and restores the original context' { Get-ScoutSubscriptionSecurityPolicySweep -Subscriptions $script:subscriptions | Out-Null @($script:contextCalls) | Should -Be @('sub-1', 'sub-2', 'original-sub') } It 'isolates a failed dataset to the affected subscription' { $script:failPolicySubscription = 'sub-2' $results = @( Get-ScoutSubscriptionSecurityPolicySweep ` -Subscriptions $script:subscriptions ` -WarningAction SilentlyContinue ) @($results[0].properties.PolicyComplianceStates).Count | Should -Be 1 @($results[1].properties.PolicyComplianceStates).Count | Should -Be 0 @($results[1].properties.DefenderAlerts).Count | Should -Be 1 $results[1].properties.CollectionStatus.PolicyComplianceStates | Should -Be 'Unavailable' @($results[1].properties.CollectionErrors).Count | Should -Be 1 $results[1].properties.CollectionErrors[0].Dataset | Should -Be 'PolicyComplianceStates' } It 'retries a transient Defender assessment failure with bounded backoff' { $script:retryAssessmentSubscription = 'sub-1' $results = @( Get-ScoutSubscriptionSecurityPolicySweep ` -Subscriptions @($script:subscriptions[0]) ) $script:assessmentAttempts['sub-1'] | Should -Be 3 @($script:sleepCalls) | Should -Be @(200, 400) $results[0].properties.DefenderAssessments[0].Name | Should -Be 'assessment-sub-1' $results[0].properties.CollectionStatus.DefenderAssessments | Should -Be 'Success' } It 'treats an unregistered Microsoft.Security pricing provider as unavailable, not an error' { $script:unregisteredPricingSubscription = 'sub-2' $results = @( Get-ScoutSubscriptionSecurityPolicySweep ` -Subscriptions $script:subscriptions ` -WarningVariable warnings ` -WarningAction SilentlyContinue ) @($results[1].properties.DefenderPricing).Count | Should -Be 0 $results[1].properties.CollectionStatus.DefenderPricing | Should -Be 'Unavailable' @($results[1].properties.CollectionErrors).Count | Should -Be 0 ($warnings -join "`n") | Should -Not -Match 'DefenderPricing' } It 'returns a skipped envelope without issuing data calls when context entry fails' { $script:failContextSubscription = 'sub-2' $results = @( Get-ScoutSubscriptionSecurityPolicySweep ` -Subscriptions $script:subscriptions ` -WarningAction SilentlyContinue ) @($results[1].properties.DefenderAlerts).Count | Should -Be 0 $results[1].properties.CollectionStatus.DefenderAlerts | Should -Be 'Skipped' $results[1].properties.CollectionErrors[0].Dataset | Should -Be 'Context' $script:assessmentAttempts.ContainsKey('sub-2') | Should -BeFalse $script:contextCalls[-1] | Should -Be 'original-sub' } It 'returns no rows and performs no context operations for an empty subscription list' { $results = @( Get-ScoutSubscriptionSecurityPolicySweep -Subscriptions @() ) $results.Count | Should -Be 0 $script:contextCalls.Count | Should -Be 0 } } |