tests/Identity.Module.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } #Requires -Modules ImportExcel <# .SYNOPSIS Pester tests for all 18 Identity inventory modules. .DESCRIPTION Tests both Processing and Reporting phases for each Identity module using synthetic mock data that mirrors the normalized PSCustomObject shape produced by Start-AZSCEntraExtraction.ps1. Includes special test infrastructure for Register-AZSCInventoryModule pattern modules (IdentityProviders, SecurityDefaults) and ARM-based modules (ManagedIds). Processing phase: Verifies each module correctly filters and transforms resources into flat hashtable arrays. Reporting phase: Verifies each module produces an Excel worksheet via Export-Excel (ImportExcel module required). NO live Azure/Graph authentication is required. .NOTES Author: AzureScout Contributors Version: 1.1.0 Created: 2026-02-23 Phase: 5.18 — Full run with Entra modules producing Excel worksheets #> # =================================================================== # DISCOVERY-TIME DEFINITIONS # Must be at script level (outside BeforeAll) so Pester v5 can resolve # them during test discovery for -ForEach parameterization. # =================================================================== $IdentityPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Modules' 'Public' 'InventoryModules' 'Identity' $ModuleSpecs = @( @{ Name = 'Users'; File = 'Users.ps1'; Type = 'entra/users'; Worksheet = 'Entra Users' } @{ Name = 'Groups'; File = 'Groups.ps1'; Type = 'entra/groups'; Worksheet = 'Entra Groups' } @{ Name = 'AppRegistrations'; File = 'AppRegistrations.ps1'; Type = 'entra/applications'; Worksheet = 'App Registrations' } @{ Name = 'ServicePrincipals'; File = 'ServicePrincipals.ps1'; Type = 'entra/serviceprincipals'; Worksheet = 'Service Principals' } @{ Name = 'ManagedIdentities'; File = 'ManagedIdentities.ps1'; Type = 'entra/managedidentities'; Worksheet = 'Managed Identities' } @{ Name = 'DirectoryRoles'; File = 'DirectoryRoles.ps1'; Type = 'entra/directoryroles'; Worksheet = 'Directory Roles' } @{ Name = 'PIMAssignments'; File = 'PIMAssignments.ps1'; Type = 'entra/pimassignments'; Worksheet = 'PIM Assignments' } @{ Name = 'ConditionalAccess'; File = 'ConditionalAccess.ps1'; Type = 'entra/conditionalaccesspolicies'; Worksheet = 'Conditional Access' } @{ Name = 'NamedLocations'; File = 'NamedLocations.ps1'; Type = 'entra/namedlocations'; Worksheet = 'Named Locations' } @{ Name = 'AdminUnits'; File = 'AdminUnits.ps1'; Type = 'entra/administrativeunits'; Worksheet = 'Admin Units' } @{ Name = 'Domains'; File = 'Domains.ps1'; Type = 'entra/domains'; Worksheet = 'Entra Domains' } @{ Name = 'Licensing'; File = 'Licensing.ps1'; Type = 'entra/subscribedskus'; Worksheet = 'Licensing' } @{ Name = 'CrossTenantAccess'; File = 'CrossTenantAccess.ps1'; Type = 'entra/crosstenantaccess'; Worksheet = 'Cross-Tenant Access' } @{ Name = 'SecurityPolicies'; File = 'SecurityPolicies.ps1'; Type = 'entra/securitypolicies'; Worksheet = 'Security Policies' } @{ Name = 'RiskyUsers'; File = 'RiskyUsers.ps1'; Type = 'entra/riskyusers'; Worksheet = 'Risky Users' } ) # =================================================================== # EXECUTION-TIME SETUP (BeforeAll runs before any tests) # =================================================================== BeforeAll { $script:ModuleRoot = Split-Path -Parent $PSScriptRoot $script:IdentityPath = Join-Path $script:ModuleRoot 'Modules' 'Public' 'InventoryModules' 'Identity' $script:TestOutputDir = Join-Path $env:TEMP 'AZSC_IdentityTests' if (Test-Path $script:TestOutputDir) { Remove-Item $script:TestOutputDir -Recurse -Force } New-Item -ItemType Directory -Path $script:TestOutputDir -Force | Out-Null # --- Helper: Build a normalized Entra resource object --- function New-MockEntraResource { param( [string]$Id, [string]$Name, [string]$Type, [string]$TenantId = '00000000-0000-0000-0000-000000000001', [object]$Properties ) [PSCustomObject]@{ id = $Id name = $Name TYPE = $Type tenantId = $TenantId properties = $Properties } } # --- Helper: Invoke a module's Processing or Reporting phase --- function Invoke-IdentityModule { param( [string]$ModuleFile, [string]$Task, [object]$Resources = $null, [object]$SmaResources = $null, [string]$File = $null, [string]$TableStyle = 'Light20' ) $content = Get-Content -Path $ModuleFile -Raw $sb = [ScriptBlock]::Create($content) # param order: $SCPath, $Sub, $Intag, $Resources, $Retirements, $Task, $File, $SmaResources, $TableStyle, $Unsupported Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $Resources, $null, $Task, $File, $SmaResources, $TableStyle, $null } # =================================================================== # Build mock resources for ALL 15 Entra types (2 items each) # =================================================================== $script:MockResources = @() # 1. Users $script:MockResources += New-MockEntraResource -Id 'u1' -Name 'Alice Smith' -Type 'entra/users' -Properties ([PSCustomObject]@{ displayName = 'Alice Smith' userPrincipalName = 'alice@contoso.com' userType = 'Member' accountEnabled = $true createdDateTime = '2025-01-15T10:00:00Z' lastPasswordChangeDateTime = '2025-06-01T08:00:00Z' assignedLicenses = @(@{ skuId = 'sku1' }, @{ skuId = 'sku2' }) onPremisesSyncEnabled = $false department = 'Engineering' jobTitle = 'Developer' mail = 'alice@contoso.com' }) $script:MockResources += New-MockEntraResource -Id 'u2' -Name 'Bob Jones' -Type 'entra/users' -Properties ([PSCustomObject]@{ displayName = 'Bob Jones' userPrincipalName = 'bob@contoso.com' userType = 'Guest' accountEnabled = $false createdDateTime = '2024-11-01T12:00:00Z' lastPasswordChangeDateTime = $null assignedLicenses = $null onPremisesSyncEnabled = $true department = 'IT' jobTitle = 'Admin' mail = 'bob@contoso.com' }) # 2. Groups $script:MockResources += New-MockEntraResource -Id 'g1' -Name 'Dev Team' -Type 'entra/groups' -Properties ([PSCustomObject]@{ displayName = 'Dev Team' groupTypes = @('Unified') securityEnabled = $true mailEnabled = $true isAssignableToRole = $false membershipRule = $null onPremisesSyncEnabled = $false description = 'Development team group' }) $script:MockResources += New-MockEntraResource -Id 'g2' -Name 'Security Admins' -Type 'entra/groups' -Properties ([PSCustomObject]@{ displayName = 'Security Admins' groupTypes = @() securityEnabled = $true mailEnabled = $false isAssignableToRole = $true membershipRule = 'user.department -eq "Security"' onPremisesSyncEnabled = $false description = 'Security administrators' }) # 3. App Registrations $script:MockResources += New-MockEntraResource -Id 'app1' -Name 'MyWebApp' -Type 'entra/applications' -Properties ([PSCustomObject]@{ displayName = 'MyWebApp' appId = 'a1b2c3d4-e5f6-0000-0000-000000000001' signInAudience = 'AzureADMyOrg' keyCredentials = @(@{ endDateTime = '2026-12-31T00:00:00Z' }) passwordCredentials = @(@{ endDateTime = '2026-06-30T00:00:00Z' }) requiredResourceAccess = @(@{ resourceAppId = '00000003-0000-0000-c000-000000000000' }) publisherDomain = 'contoso.com' createdDateTime = '2025-03-01T09:00:00Z' }) $script:MockResources += New-MockEntraResource -Id 'app2' -Name 'BackendAPI' -Type 'entra/applications' -Properties ([PSCustomObject]@{ displayName = 'BackendAPI' appId = 'a1b2c3d4-e5f6-0000-0000-000000000002' signInAudience = 'AzureADMultipleOrgs' keyCredentials = $null passwordCredentials = $null requiredResourceAccess = $null publisherDomain = 'contoso.com' createdDateTime = '2025-05-10T14:00:00Z' }) # 4. Service Principals $script:MockResources += New-MockEntraResource -Id 'sp1' -Name 'MyWebApp SP' -Type 'entra/serviceprincipals' -Properties ([PSCustomObject]@{ displayName = 'MyWebApp SP' appId = 'a1b2c3d4-e5f6-0000-0000-000000000001' servicePrincipalType = 'Application' accountEnabled = $true appOwnerOrganizationId = '00000000-0000-0000-0000-000000000001' keyCredentials = @(@{ endDateTime = '2026-12-31T00:00:00Z' }) passwordCredentials = @(@{ endDateTime = '2026-06-30T00:00:00Z' }) tags = @('WindowsAzureActiveDirectoryIntegratedApp') }) $script:MockResources += New-MockEntraResource -Id 'sp2' -Name 'Graph Explorer' -Type 'entra/serviceprincipals' -Properties ([PSCustomObject]@{ displayName = 'Graph Explorer' appId = 'a1b2c3d4-e5f6-0000-0000-000000000099' servicePrincipalType = 'ManagedIdentity' accountEnabled = $false appOwnerOrganizationId = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a' keyCredentials = $null passwordCredentials = $null tags = $null }) # 5. Managed Identities $script:MockResources += New-MockEntraResource -Id 'mi1' -Name 'webapp-identity' -Type 'entra/managedidentities' -Properties ([PSCustomObject]@{ displayName = 'webapp-identity' appId = 'mi-app-id-001' alternativeNames = @('isExplicit=True', '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Web/sites/myapp') }) $script:MockResources += New-MockEntraResource -Id 'mi2' -Name 'vm-system-identity' -Type 'entra/managedidentities' -Properties ([PSCustomObject]@{ displayName = 'vm-system-identity' appId = 'mi-app-id-002' alternativeNames = @('isExplicit=False', '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/myvm') }) # 6. Directory Roles $script:MockResources += New-MockEntraResource -Id 'dr1' -Name 'Global Administrator' -Type 'entra/directoryroles' -Properties ([PSCustomObject]@{ displayName = 'Global Administrator' roleTemplateId = '62e90394-69f5-4237-9190-012177145e10' description = 'Can manage all aspects of Azure AD and Microsoft services.' }) $script:MockResources += New-MockEntraResource -Id 'dr2' -Name 'User Administrator' -Type 'entra/directoryroles' -Properties ([PSCustomObject]@{ displayName = 'User Administrator' roleTemplateId = 'fe930be7-5e62-47db-91af-98c3a49a38b1' description = 'Can manage all aspects of users and groups.' }) # 7. PIM Assignments $script:MockResources += New-MockEntraResource -Id 'pim1' -Name 'PIM-Alice-GA' -Type 'entra/pimassignments' -Properties ([PSCustomObject]@{ id = 'pim-assign-001' principalId = 'u1' roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10' directoryScopeId = '/' principal = [PSCustomObject]@{ displayName = 'Alice Smith' '@odata.type' = '#microsoft.graph.user' } roleDefinition = [PSCustomObject]@{ displayName = 'Global Administrator' } }) $script:MockResources += New-MockEntraResource -Id 'pim2' -Name 'PIM-Group-UA' -Type 'entra/pimassignments' -Properties ([PSCustomObject]@{ id = 'pim-assign-002' principalId = 'g2' roleDefinitionId = 'fe930be7-5e62-47db-91af-98c3a49a38b1' directoryScopeId = '/' principal = [PSCustomObject]@{ displayName = 'Security Admins' '@odata.type' = '#microsoft.graph.group' } roleDefinition = [PSCustomObject]@{ displayName = 'User Administrator' } }) # 8. Conditional Access Policies $script:MockResources += New-MockEntraResource -Id 'ca1' -Name 'Require MFA for All' -Type 'entra/conditionalaccesspolicies' -Properties ([PSCustomObject]@{ displayName = 'Require MFA for All' state = 'enabled' conditions = [PSCustomObject]@{ users = [PSCustomObject]@{ includeUsers = @('All') excludeUsers = @('breakglass@contoso.com') } applications = [PSCustomObject]@{ includeApplications = @('All') } } grantControls = [PSCustomObject]@{ builtInControls = @('mfa') } sessionControls = [PSCustomObject]@{ signInFrequency = [PSCustomObject]@{ value = 1; type = 'hours' } persistentBrowser = $null cloudAppSecurity = $null applicationEnforcedRestrictions = $null } createdDateTime = '2025-02-01T10:00:00Z' modifiedDateTime = '2025-06-15T14:30:00Z' }) $script:MockResources += New-MockEntraResource -Id 'ca2' -Name 'Block Legacy Auth' -Type 'entra/conditionalaccesspolicies' -Properties ([PSCustomObject]@{ displayName = 'Block Legacy Auth' state = 'disabled' conditions = [PSCustomObject]@{ users = [PSCustomObject]@{ includeUsers = @('All') excludeUsers = $null } applications = [PSCustomObject]@{ includeApplications = @('All') } } grantControls = [PSCustomObject]@{ builtInControls = @('block') } sessionControls = $null createdDateTime = '2025-03-10T08:00:00Z' modifiedDateTime = $null }) # 9. Named Locations $script:MockResources += New-MockEntraResource -Id 'nl1' -Name 'Corporate Network' -Type 'entra/namedlocations' -Properties ([PSCustomObject]@{ displayName = 'Corporate Network' '@odata.type' = '#microsoft.graph.ipNamedLocation' isTrusted = $true ipRanges = @([PSCustomObject]@{ cidrAddress = '10.0.0.0/8' }, [PSCustomObject]@{ cidrAddress = '172.16.0.0/12' }) createdDateTime = '2025-01-01T00:00:00Z' modifiedDateTime = '2025-05-20T12:00:00Z' }) $script:MockResources += New-MockEntraResource -Id 'nl2' -Name 'Allowed Countries' -Type 'entra/namedlocations' -Properties ([PSCustomObject]@{ displayName = 'Allowed Countries' '@odata.type' = '#microsoft.graph.countryNamedLocation' countriesAndRegions = @('US', 'CA', 'GB') createdDateTime = '2025-02-15T09:00:00Z' modifiedDateTime = $null }) # 10. Admin Units $script:MockResources += New-MockEntraResource -Id 'au1' -Name 'Engineering AU' -Type 'entra/administrativeunits' -Properties ([PSCustomObject]@{ displayName = 'Engineering AU' description = 'Administrative unit for engineering' membershipType = 'Dynamic' membershipRule = 'user.department -eq "Engineering"' visibility = 'Public' }) $script:MockResources += New-MockEntraResource -Id 'au2' -Name 'HR AU' -Type 'entra/administrativeunits' -Properties ([PSCustomObject]@{ displayName = 'HR AU' description = 'Administrative unit for HR' membershipType = $null membershipRule = $null visibility = 'HiddenMembership' }) # 11. Domains $script:MockResources += New-MockEntraResource -Id 'd1' -Name 'contoso.com' -Type 'entra/domains' -Properties ([PSCustomObject]@{ id = 'contoso.com' isVerified = $true isDefault = $true isAdminManaged = $true authenticationType = 'Managed' supportedServices = @('Email', 'OfficeCommunicationsOnline') }) $script:MockResources += New-MockEntraResource -Id 'd2' -Name 'contoso.onmicrosoft.com' -Type 'entra/domains' -Properties ([PSCustomObject]@{ id = 'contoso.onmicrosoft.com' isVerified = $true isDefault = $false isAdminManaged = $false authenticationType = 'Managed' supportedServices = @('Email') }) # 12. Licensing (Subscribed SKUs) $script:MockResources += New-MockEntraResource -Id 'lic1' -Name 'E5 License' -Type 'entra/subscribedskus' -Properties ([PSCustomObject]@{ skuPartNumber = 'SPE_E5' skuId = '06ebc4ee-1bb5-47dd-8120-11324bc54e06' consumedUnits = 45 prepaidUnits = [PSCustomObject]@{ enabled = 100; suspended = 0; warning = 0 } appliesTo = 'User' capabilityStatus = 'Enabled' }) $script:MockResources += New-MockEntraResource -Id 'lic2' -Name 'P2 License' -Type 'entra/subscribedskus' -Properties ([PSCustomObject]@{ skuPartNumber = 'AAD_PREMIUM_P2' skuId = '84a661c4-e949-4bd2-a560-ed7766fcaf2b' consumedUnits = 10 prepaidUnits = [PSCustomObject]@{ enabled = 50; suspended = 5; warning = 2 } appliesTo = 'User' capabilityStatus = 'Warning' }) # 13. Cross-Tenant Access $script:MockResources += New-MockEntraResource -Id 'cta1' -Name 'Partner Org' -Type 'entra/crosstenantaccess' -Properties ([PSCustomObject]@{ tenantId = '11111111-1111-1111-1111-111111111111' inboundTrust = [PSCustomObject]@{ isMfaAccepted = $true isCompliantDeviceAccepted = $true isHybridAzureADJoinedDeviceAccepted = $false } b2bCollaborationInbound = [PSCustomObject]@{ applications = [PSCustomObject]@{ accessType = 'allowed' } } b2bDirectConnectInbound = [PSCustomObject]@{ applications = [PSCustomObject]@{ accessType = 'blocked' } } isServiceProvider = $false }) $script:MockResources += New-MockEntraResource -Id 'cta2' -Name 'Vendor Org' -Type 'entra/crosstenantaccess' -Properties ([PSCustomObject]@{ tenantId = '22222222-2222-2222-2222-222222222222' inboundTrust = $null b2bCollaborationInbound = $null b2bDirectConnectInbound = $null isServiceProvider = $true }) # 14. Security Policies (Authorization Policy) $script:MockResources += New-MockEntraResource -Id 'secpol1' -Name 'Authorization Policy' -Type 'entra/securitypolicies' -Properties ([PSCustomObject]@{ guestUserRoleId = '10dae51f-b6af-4016-8d66-8c2a99b929b3' allowInvitesFrom = 'adminsAndGuestInviters' allowedToSignUpEmailBasedSubscriptions = $true allowEmailVerifiedUsersToJoinOrganization = $false allowedToUseSSPR = $true blockMsolPowerShell = $false defaultUserRolePermissions = [PSCustomObject]@{ allowedToCreateApps = $true allowedToCreateSecurityGroups = $false allowedToReadOtherUsers = $true } }) # 15. Risky Users $script:MockResources += New-MockEntraResource -Id 'risk1' -Name 'risky-alice' -Type 'entra/riskyusers' -Properties ([PSCustomObject]@{ userPrincipalName = 'alice@contoso.com' userDisplayName = 'Alice Smith' riskLevel = 'high' riskState = 'atRisk' riskDetail = 'adminConfirmedCompromised' riskLastUpdatedDateTime = '2025-06-20T15:00:00Z' isDeleted = $false isProcessing = $false }) $script:MockResources += New-MockEntraResource -Id 'risk2' -Name 'risky-bob' -Type 'entra/riskyusers' -Properties ([PSCustomObject]@{ userPrincipalName = 'bob@contoso.com' userDisplayName = 'Bob Jones' riskLevel = 'medium' riskState = 'confirmedCompromised' riskDetail = 'userPassedMFADrivenByRiskBasedPolicy' riskLastUpdatedDateTime = '2025-06-18T09:00:00Z' isDeleted = $false isProcessing = $true }) # Pre-compute module specs in script scope for execution-time use $script:ModuleSpecs = @( @{ Name = 'Users'; File = 'Users.ps1'; Type = 'entra/users'; Worksheet = 'Entra Users' } @{ Name = 'Groups'; File = 'Groups.ps1'; Type = 'entra/groups'; Worksheet = 'Entra Groups' } @{ Name = 'AppRegistrations'; File = 'AppRegistrations.ps1'; Type = 'entra/applications'; Worksheet = 'App Registrations' } @{ Name = 'ServicePrincipals'; File = 'ServicePrincipals.ps1'; Type = 'entra/serviceprincipals'; Worksheet = 'Service Principals' } @{ Name = 'ManagedIdentities'; File = 'ManagedIdentities.ps1'; Type = 'entra/managedidentities'; Worksheet = 'Managed Identities' } @{ Name = 'DirectoryRoles'; File = 'DirectoryRoles.ps1'; Type = 'entra/directoryroles'; Worksheet = 'Directory Roles' } @{ Name = 'PIMAssignments'; File = 'PIMAssignments.ps1'; Type = 'entra/pimassignments'; Worksheet = 'PIM Assignments' } @{ Name = 'ConditionalAccess'; File = 'ConditionalAccess.ps1'; Type = 'entra/conditionalaccesspolicies'; Worksheet = 'Conditional Access' } @{ Name = 'NamedLocations'; File = 'NamedLocations.ps1'; Type = 'entra/namedlocations'; Worksheet = 'Named Locations' } @{ Name = 'AdminUnits'; File = 'AdminUnits.ps1'; Type = 'entra/administrativeunits'; Worksheet = 'Admin Units' } @{ Name = 'Domains'; File = 'Domains.ps1'; Type = 'entra/domains'; Worksheet = 'Entra Domains' } @{ Name = 'Licensing'; File = 'Licensing.ps1'; Type = 'entra/subscribedskus'; Worksheet = 'Licensing' } @{ Name = 'CrossTenantAccess'; File = 'CrossTenantAccess.ps1'; Type = 'entra/crosstenantaccess'; Worksheet = 'Cross-Tenant Access' } @{ Name = 'SecurityPolicies'; File = 'SecurityPolicies.ps1'; Type = 'entra/securitypolicies'; Worksheet = 'Security Policies' } @{ Name = 'RiskyUsers'; File = 'RiskyUsers.ps1'; Type = 'entra/riskyusers'; Worksheet = 'Risky Users' } ) } AfterAll { # Clean up temp output if (Test-Path $script:TestOutputDir) { Remove-Item $script:TestOutputDir -Recurse -Force } } # ===================================================================== # PROCESSING PHASE TESTS — uses -ForEach for Pester v5 discovery # ===================================================================== Describe 'Identity Module Processing Phase' -Tag 'Processing' { Context '<Name> module' -ForEach $ModuleSpecs { BeforeAll { $moduleFile = Join-Path $script:IdentityPath $File $script:procResult = Invoke-IdentityModule -ModuleFile $moduleFile -Task 'Processing' -Resources $script:MockResources } It 'Module file exists' { (Join-Path $script:IdentityPath $File) | Should -Exist } It 'Returns non-null output' { $script:procResult | Should -Not -BeNullOrEmpty } It 'Returns at least one record' { @($script:procResult).Count | Should -BeGreaterOrEqual 1 } It 'Each record contains a Resource U key' { foreach ($record in @($script:procResult)) { $record.'Resource U' | Should -Not -BeNullOrEmpty } } } } # ===================================================================== # REPORTING PHASE TESTS (Excel worksheet generation) # ===================================================================== Describe 'Identity Module Reporting Phase' -Tag 'Reporting' { BeforeAll { $script:ExcelFile = Join-Path $script:TestOutputDir 'EntraIdentityTest.xlsx' # First run Processing for ALL modules to collect SmaResources $script:ProcessedData = @{} foreach ($spec in $script:ModuleSpecs) { $moduleFile = Join-Path $script:IdentityPath $spec.File $procResult = Invoke-IdentityModule -ModuleFile $moduleFile -Task 'Processing' -Resources $script:MockResources $script:ProcessedData[$spec.Name] = $procResult } # Now run Reporting for each module, all writing to the same Excel file foreach ($spec in $script:ModuleSpecs) { $moduleFile = Join-Path $script:IdentityPath $spec.File $sma = $script:ProcessedData[$spec.Name] if ($sma) { Invoke-IdentityModule -ModuleFile $moduleFile -Task 'Reporting' -SmaResources $sma -File $script:ExcelFile -TableStyle 'Light20' } } } It 'Excel report file was created' { $script:ExcelFile | Should -Exist } It 'Excel file is non-empty' { (Get-Item $script:ExcelFile).Length | Should -BeGreaterThan 0 } Context 'Worksheet <Worksheet>' -ForEach $ModuleSpecs { It 'Exists in Excel file' { $worksheets = (Get-ExcelSheetInfo -Path $script:ExcelFile).Name $worksheets | Should -Contain $Worksheet } It 'Has data rows' { $data = Import-Excel -Path $script:ExcelFile -WorksheetName $Worksheet @($data).Count | Should -BeGreaterOrEqual 1 } } } # ===================================================================== # EDGE CASE TESTS # ===================================================================== Describe 'Identity Module Edge Cases' -Tag 'EdgeCases' { It 'Processing returns nothing for empty resource set' { $moduleFile = Join-Path $script:IdentityPath 'Users.ps1' $result = Invoke-IdentityModule -ModuleFile $moduleFile -Task 'Processing' -Resources @() $result | Should -BeNullOrEmpty } It 'Processing returns nothing when no matching TYPE exists' { $fakeResources = @( New-MockEntraResource -Id 'x1' -Name 'Fake' -Type 'entra/fake' -Properties ([PSCustomObject]@{ displayName = 'Fake' }) ) $moduleFile = Join-Path $script:IdentityPath 'Users.ps1' $result = Invoke-IdentityModule -ModuleFile $moduleFile -Task 'Processing' -Resources $fakeResources $result | Should -BeNullOrEmpty } It 'Reporting with null SmaResources does not throw' { $moduleFile = Join-Path $script:IdentityPath 'Users.ps1' $tempFile = Join-Path $script:TestOutputDir 'edge_empty.xlsx' { Invoke-IdentityModule -ModuleFile $moduleFile -Task 'Reporting' -SmaResources $null -File $tempFile } | Should -Not -Throw } } # ===================================================================== # MANAGEDIDS MODULE TESTS (ARM-based, different from Entra pattern) # ===================================================================== Describe 'ManagedIds Module (ARM-based)' -Tag 'ManagedIds' { BeforeAll { $script:ManagedIdsFile = Join-Path $script:IdentityPath 'ManagedIds.ps1' # ARM-style mock resources (different from Entra format) $script:ManagedIdResources = @( [PSCustomObject]@{ id = '/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-test/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity-1' Name = 'test-identity-1' TYPE = 'Microsoft.ManagedIdentity/userAssignedIdentities' location = 'eastus' tags = [PSCustomObject]@{ env = 'production'; team = 'platform' } PROPERTIES = [PSCustomObject]@{ principalId = 'pid-001-aaaa-bbbb-cccc' clientId = 'cid-001-dddd-eeee-ffff' } }, [PSCustomObject]@{ id = '/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-dev/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity-2' Name = 'test-identity-2' TYPE = 'Microsoft.ManagedIdentity/userAssignedIdentities' location = 'westus2' tags = [PSCustomObject]@{ env = 'dev' } PROPERTIES = [PSCustomObject]@{ principalId = 'pid-002-aaaa-bbbb-cccc' clientId = 'cid-002-dddd-eeee-ffff' } } ) # Mock subscription array for $Sub lookup $script:MockSubs = @( [PSCustomObject]@{ id = '00000000-0000-0000-0000-000000000001' Name = 'Test Subscription' } ) # Run processing once $content = Get-Content -Path $script:ManagedIdsFile -Raw $sb = [ScriptBlock]::Create($content) $script:ManagedIdProcResult = Invoke-Command -ScriptBlock $sb -ArgumentList ` $null, $script:MockSubs, $null, $script:ManagedIdResources, $null, 'Processing', $null, $null, 'Light20', $null } It 'Module file exists' { $script:ManagedIdsFile | Should -Exist } It 'Processing returns non-null output' { $script:ManagedIdProcResult | Should -Not -BeNullOrEmpty } It 'Processing returns records with expected keys' { $first = @($script:ManagedIdProcResult)[0] $first.Keys | Should -Contain 'ID' $first.Keys | Should -Contain 'Subscription' $first.Keys | Should -Contain 'Name' $first.Keys | Should -Contain 'Location' $first.Keys | Should -Contain 'Principal ID' $first.Keys | Should -Contain 'Client ID' $first.Keys | Should -Contain 'Resource U' } It 'Processing explodes tags into separate records' { # First resource has 2 tags, second has 1 = 3 records total @($script:ManagedIdProcResult).Count | Should -Be 3 } It 'Reporting produces Excel worksheet' { $excelFile = Join-Path $script:TestOutputDir 'ManagedIds_Test.xlsx' $content = Get-Content -Path $script:ManagedIdsFile -Raw $sb = [ScriptBlock]::Create($content) Invoke-Command -ScriptBlock $sb -ArgumentList ` $null, $null, $null, $null, $null, 'Reporting', $excelFile, $script:ManagedIdProcResult, 'Light20', $null $excelFile | Should -Exist $worksheets = (Get-ExcelSheetInfo -Path $excelFile).Name $worksheets | Should -Contain 'Managed Identity' } } # ===================================================================== # IDENTITYPROVIDERS MODULE (Register-AZSCInventoryModule pattern) # ===================================================================== Describe 'IdentityProviders Module (Register-AZSCInventoryModule)' -Tag 'IdentityProviders' { BeforeAll { $script:IdentityProvidersFile = Join-Path $script:IdentityPath 'IdentityProviders.ps1' # Framework mock functions $script:RegisteredModules = @{} $script:ProcessedDataStore = @{} function Register-AZSCInventoryModule { param([string]$ModuleId, [string]$PhaseId, [scriptblock]$ScriptBlock) if (-not $script:RegisteredModules[$ModuleId]) { $script:RegisteredModules[$ModuleId] = @{} } $script:RegisteredModules[$ModuleId][$PhaseId] = $ScriptBlock } function Write-AZSCLog { param([string]$Message, [string]$Level) } function Add-AZSCProcessedData { param([string]$Type, $Data) if (-not $script:ProcessedDataStore[$Type]) { $script:ProcessedDataStore[$Type] = [System.Collections.Generic.List[object]]::new() } $script:ProcessedDataStore[$Type].Add($Data) } function Get-AZSCProcessedData { param([string]$Type) return $script:ProcessedDataStore[$Type] } # Dot-source the module to register scriptblocks . $script:IdentityProvidersFile # Mock identity provider data $script:IPContext = @{ EntraData = @{ 'entra/identityproviders' = @( [PSCustomObject]@{ id = 'provider-001' displayName = 'Google' '@odata.type' = '#microsoft.graph.socialIdentityProvider' identityProviderType = 'Google' clientId = 'google-client-id-001' clientSecret = 'secret123' issuerUri = $null metadataUrl = $null openIdConnectDiscoveryEndpoint = $null domainsHint = @('gmail.com', 'googlemail.com') responseMode = $null responseType = $null scope = $null isEnabled = $true }, [PSCustomObject]@{ id = 'provider-002' displayName = 'Corporate SAML' name = 'Corporate SAML' '@odata.type' = '#microsoft.graph.samlOrWsFedProvider' identityProviderType = 'SAML' clientId = $null appId = 'saml-app-id-002' clientSecret = $null issuerUri = 'https://idp.contoso.com' metadataUrl = $null openIdConnectDiscoveryEndpoint = $null domainsHint = $null responseMode = 'form_post' responseType = 'id_token' scope = 'openid' isEnabled = $false } ) } TenantId = '00000000-0000-0000-0000-000000000001' ExcelPath = (Join-Path $script:TestOutputDir 'IdentityProviders_Test.xlsx') } # Run processing in BeforeAll $processingBlock = $script:RegisteredModules['entra/identityproviders']['Processing'] & $processingBlock -Context $script:IPContext } It 'Module file exists' { $script:IdentityProvidersFile | Should -Exist } It 'Registered Processing and Reporting scriptblocks' { $script:RegisteredModules['entra/identityproviders'] | Should -Not -BeNullOrEmpty $script:RegisteredModules['entra/identityproviders']['Processing'] | Should -Not -BeNullOrEmpty $script:RegisteredModules['entra/identityproviders']['Reporting'] | Should -Not -BeNullOrEmpty } It 'Processing creates 2 processed data records' { $data = Get-AZSCProcessedData -Type 'entra/identityproviders' $data | Should -Not -BeNullOrEmpty @($data).Count | Should -Be 2 } It 'Processed records have expected properties' { $data = Get-AZSCProcessedData -Type 'entra/identityproviders' $first = @($data)[0] $first.Name | Should -Not -BeNullOrEmpty $first.Id | Should -Not -BeNullOrEmpty $first.Type | Should -Not -BeNullOrEmpty $first.IdentityProviderType | Should -Not -BeNullOrEmpty $first.ClientId | Should -Not -BeNullOrEmpty } It 'Reporting does not throw' { $reportingBlock = $script:RegisteredModules['entra/identityproviders']['Reporting'] { & $reportingBlock -Context $script:IPContext } | Should -Not -Throw } } # ===================================================================== # SECURITYDEFAULTS MODULE (Register-AZSCInventoryModule pattern) # ===================================================================== Describe 'SecurityDefaults Module (Register-AZSCInventoryModule)' -Tag 'SecurityDefaults' { BeforeAll { $script:SecurityDefaultsFile = Join-Path $script:IdentityPath 'SecurityDefaults.ps1' # Framework mock functions (re-define in this scope) $script:RegisteredModules = @{} $script:ProcessedDataStore = @{} function Register-AZSCInventoryModule { param([string]$ModuleId, [string]$PhaseId, [scriptblock]$ScriptBlock) if (-not $script:RegisteredModules[$ModuleId]) { $script:RegisteredModules[$ModuleId] = @{} } $script:RegisteredModules[$ModuleId][$PhaseId] = $ScriptBlock } function Write-AZSCLog { param([string]$Message, [string]$Level) } function Add-AZSCProcessedData { param([string]$Type, $Data) if (-not $script:ProcessedDataStore[$Type]) { $script:ProcessedDataStore[$Type] = [System.Collections.Generic.List[object]]::new() } $script:ProcessedDataStore[$Type].Add($Data) } function Get-AZSCProcessedData { param([string]$Type) return $script:ProcessedDataStore[$Type] } # Dot-source the module to register scriptblocks . $script:SecurityDefaultsFile # Mock security defaults data $script:SDContext = @{ EntraData = @{ 'entra/securitydefaults' = @( [PSCustomObject]@{ id = 'secdef-001' displayName = 'Security Defaults Enforcement Policy' isEnabled = $true description = 'Preset security policies for the tenant' lastModifiedDateTime = '2025-03-15T10:30:00Z' } ) } TenantId = '00000000-0000-0000-0000-000000000001' ExcelPath = (Join-Path $script:TestOutputDir 'SecurityDefaults_Test.xlsx') } # Run processing in BeforeAll $processingBlock = $script:RegisteredModules['entra/securitydefaults']['Processing'] & $processingBlock -Context $script:SDContext } It 'Module file exists' { $script:SecurityDefaultsFile | Should -Exist } It 'Registered Processing and Reporting scriptblocks' { $script:RegisteredModules['entra/securitydefaults'] | Should -Not -BeNullOrEmpty $script:RegisteredModules['entra/securitydefaults']['Processing'] | Should -Not -BeNullOrEmpty $script:RegisteredModules['entra/securitydefaults']['Reporting'] | Should -Not -BeNullOrEmpty } It 'Processing creates 1 processed data record' { $data = Get-AZSCProcessedData -Type 'entra/securitydefaults' $data | Should -Not -BeNullOrEmpty @($data).Count | Should -Be 1 } It 'Processed record has expected properties' { $data = Get-AZSCProcessedData -Type 'entra/securitydefaults' $first = @($data)[0] $first.TenantId | Should -Not -BeNullOrEmpty $first.PolicyName | Should -Not -BeNullOrEmpty $first.Enabled | Should -Be 'Yes' $first.ProtectionsProvided | Should -Not -BeNullOrEmpty $first.RecommendationStatus | Should -Not -BeNullOrEmpty } It 'Reporting does not throw' { $reportingBlock = $script:RegisteredModules['entra/securitydefaults']['Reporting'] { & $reportingBlock -Context $script:SDContext } | Should -Not -Throw } } |