tests/Export-MsecEntraGroupMemberReport.Tests.ps1
|
#Requires -Module Pester # # Tests for Export-MsecEntraGroupMemberReport. # # What is specific here is the sheet-per-group shape and the Summary that compares them. The # things easy to get wrong: # # * two groups can share a DISPLAY NAME, so sheets must be keyed on the group id - merging # them would put two different groups' members on one sheet under one heading # * an empty group must still get a sheet and a summary row: "nobody is in it" is a finding # that disappears if empty groups are skipped # * a group whose membership could not be read must count as Unreadable, never as 0 members # * a worksheet called 'Summary' or 'Dashboard' belongs to the report, so a group with that # name must be suffixed rather than overwrite it $script:HasExcel = $null -ne (Get-Module -ListAvailable ImportExcel) BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop $script:TestThumbBytes = [byte[]](1..20) } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Export-MsecEntraGroupMemberReport' -Skip:(-not $script:HasExcel) { BeforeEach { InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } { param($Thumb) $script:MsecSession = @{ TenantId = 'tenant-1'; ClientId = 'client'; KeyVaultName = 'kv' KeyName = 'msec-app'; ThumbprintBytes = $Thumb; Tokens = @{} } } $script:Book = Join-Path ([System.IO.Path]::GetTempPath()) "msec-gm-$([guid]::NewGuid().Guid).xlsx" } AfterEach { if ($script:Book -and (Test-Path $script:Book)) { Remove-Item $script:Book -Force -ErrorAction SilentlyContinue } } It 'writes a worksheet per group and one Summary row each' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='sg-admins'; GroupId='g1'; GroupType='Security'; IsRoleAssignable=$true MemberName='Ada'; MemberUserPrincipalName='ada@x.com'; MemberType='user'; MemberId='u1' MembershipType='Active'; AccountEnabled=$true; UserType='Member' } [pscustomobject]@{ GroupName='sg-admins'; GroupId='g1'; GroupType='Security'; IsRoleAssignable=$true MemberName='Eve'; MemberUserPrincipalName='eve@x.com'; MemberType='user'; MemberId='u2' MembershipType='Eligible'; AccountEnabled=$true; UserType='Member' } [pscustomobject]@{ GroupName='sg-devops'; GroupId='g2'; GroupType='Security'; IsRoleAssignable=$false MemberName='ci-runner'; MemberType='servicePrincipal'; MemberId='sp1' MembershipType='Active'; AccountEnabled=$true } [pscustomobject]@{ GroupName='sg-devops'; GroupId='g2'; GroupType='Security'; IsRoleAssignable=$false MemberName='Guest User'; MemberUserPrincipalName='g@x.com'; MemberType='user'; MemberId='u3' MembershipType='Active'; AccountEnabled=$false; UserType='Guest' } } Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-*' -WarningAction SilentlyContinue | Out-Null } $package = Open-ExcelPackage -Path $script:Book try { $names = @($package.Workbook.Worksheets | ForEach-Object { $_.Name }) $names[0] | Should -Be 'Dashboard' # in front of the data $names | Should -Contain 'sg-admins' $names | Should -Contain 'sg-devops' $names | Should -Contain 'Summary' } finally { Close-ExcelPackage $package -NoSave } @(Import-Excel -Path $script:Book -WorksheetName 'sg-admins').Count | Should -Be 2 @(Import-Excel -Path $script:Book -WorksheetName 'sg-devops').Count | Should -Be 2 $summary = @(Import-Excel -Path $script:Book -WorksheetName 'Summary') @($summary).Count | Should -Be 2 $admins = $summary | Where-Object GroupName -eq 'sg-admins' $admins.Members | Should -Be 2 $admins.Active | Should -Be 1 # Standing versus PIM-eligible is the distinction the Summary exists to show. $admins.Eligible | Should -Be 1 $devops = $summary | Where-Object GroupName -eq 'sg-devops' $devops.ServicePrincipals | Should -Be 1 $devops.Guests | Should -Be 1 # Disabled and still in an access group: cannot sign in today, membership survives. $devops.Disabled | Should -Be 1 } It 'charts groups against each other, not membership types within one' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='sg-a'; GroupId='g1'; MemberName='Ada'; MemberType='user'; MemberId='u1'; MembershipType='Active'; AccountEnabled=$true } [pscustomobject]@{ GroupName='sg-b'; GroupId='g2'; MemberName='Bob'; MemberType='user'; MemberId='u2'; MembershipType='Active'; AccountEnabled=$true } } Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-*' -WarningAction SilentlyContinue | Out-Null } $package = Open-ExcelPackage -Path $script:Book try { $charts = @($package.Workbook.Worksheets['Dashboard'].Drawings) # ONE chart comparing groups, not one per group. @($charts).Count | Should -Be 1 $charts[0].Name | Should -Be 'chartGroupMembership' @($charts[0].Series | ForEach-Object { $_.Header }) | Should -Be @('Members', 'Eligible', 'Guests', 'ServicePrincipals', 'Disabled') } finally { Close-ExcelPackage $package -NoSave } } It 'keeps two groups that share a display name on separate sheets' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) # Entra display names are not unique. Keying sheets on the NAME would merge these. Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='sg-dup'; GroupId='g1'; MemberName='Ada'; MemberType='user'; MemberId='u1'; MembershipType='Active'; AccountEnabled=$true } [pscustomobject]@{ GroupName='sg-dup'; GroupId='g2'; MemberName='Bob'; MemberType='user'; MemberId='u2'; MembershipType='Active'; AccountEnabled=$true } } Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-dup' -WarningAction SilentlyContinue | Out-Null } $summary = @(Import-Excel -Path $script:Book -WorksheetName 'Summary') @($summary).Count | Should -Be 2 # Two distinct worksheets, and the ids that tell them apart. @($summary | ForEach-Object { $_.Worksheet } | Select-Object -Unique).Count | Should -Be 2 @($summary | ForEach-Object { $_.GroupId } | Sort-Object) | Should -Be @('g1', 'g2') } It 'gives an empty group a sheet, and marks an unreadable one as such' { $warnings = @() InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraGroupMember -MockWith { # Get-MsecEntraGroupMember emits a placeholder row for each of these. [pscustomobject]@{ GroupName='sg-empty'; GroupId='g1'; MemberName=$null; MemberType='None'; MemberId=$null; MembershipType=$null } [pscustomobject]@{ GroupName='sg-denied'; GroupId='g2'; MemberName=$null; MemberType='Unreadable'; MemberId=$null; MembershipType=$null } } Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-*' } -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null $summary = @(Import-Excel -Path $script:Book -WorksheetName 'Summary') @($summary).Count | Should -Be 2 # "Nobody is in it" is a finding, and it disappears if empty groups are skipped. ($summary | Where-Object GroupName -eq 'sg-empty').Members | Should -Be 0 # A group nobody could read must never be reported as a clean zero. ($summary | Where-Object GroupName -eq 'sg-denied').Members | Should -Be 0 ($summary | Where-Object GroupName -eq 'sg-denied').Unreadable | Should -Be $true ($summary | Where-Object GroupName -eq 'sg-empty').Unreadable | Should -Be $false ($warnings -join ' ') | Should -Match 'Unreadable' ($warnings -join ' ') | Should -Match 'no members at all' } It 'does not let a group called Summary overwrite the report sheet' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='Summary'; GroupId='g1'; MemberName='Ada'; MemberType='user'; MemberId='u1'; MembershipType='Active'; AccountEnabled=$true } } Export-MsecEntraGroupMemberReport -Path $Book -Name 'Summary' -WarningAction SilentlyContinue | Out-Null } $rows = @(Import-Excel -Path $script:Book -WorksheetName 'Summary') # The Summary sheet is still the report's, with one row about the group. $rows[0].GroupName | Should -Be 'Summary' $rows[0].Members | Should -Be 1 # ...and the group's members went somewhere else. $rows[0].Worksheet | Should -Not -Be 'Summary' @(Import-Excel -Path $script:Book -WorksheetName $rows[0].Worksheet).Count | Should -Be 1 } It 'asks once for the whole run, however many groups are in it' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='sg-a'; GroupId='g1'; MemberName='Ada'; MemberType='user'; MemberId='u1'; MembershipType='Active'; AccountEnabled=$true } [pscustomobject]@{ GroupName='sg-b'; GroupId='g2'; MemberName='Bob'; MemberType='user'; MemberId='u2'; MembershipType='Active'; AccountEnabled=$true } [pscustomobject]@{ GroupName='sg-c'; GroupId='g3'; MemberName='Cleo'; MemberType='user'; MemberId='u3'; MembershipType='Active'; AccountEnabled=$true } } Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-*' -WarningAction SilentlyContinue | Out-Null # Second run, declining. Three groups must be ONE question - forty prompts is a # prompt nobody reads. Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='sg-a'; GroupId='g1'; MemberName='Changed'; MemberType='user'; MemberId='u9'; MembershipType='Active'; AccountEnabled=$true } [pscustomobject]@{ GroupName='sg-b'; GroupId='g2'; MemberName='Changed'; MemberType='user'; MemberId='u9'; MembershipType='Active'; AccountEnabled=$true } [pscustomobject]@{ GroupName='sg-c'; GroupId='g3'; MemberName='Changed'; MemberType='user'; MemberId='u9'; MembershipType='Active'; AccountEnabled=$true } } Mock Confirm-MsecEvidenceOverwrite -MockWith { $false } Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-*' -WarningAction SilentlyContinue | Out-Null # Asked once for the whole run, not once per group. Should -Invoke Confirm-MsecEvidenceOverwrite -Times 1 -Exactly } # Declining left every sheet exactly as it was. (Import-Excel -Path $script:Book -WorksheetName 'sg-a')[0].MemberName | Should -Be 'Ada' (Import-Excel -Path $script:Book -WorksheetName 'sg-c')[0].MemberName | Should -Be 'Cleo' } It '-Force replaces without asking at all' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='sg-a'; GroupId='g1'; MemberName='Ada'; MemberType='user'; MemberId='u1'; MembershipType='Active'; AccountEnabled=$true } } Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-a' -WarningAction SilentlyContinue | Out-Null Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='sg-a'; GroupId='g1'; MemberName='Forced'; MemberType='user'; MemberId='u9'; MembershipType='Active'; AccountEnabled=$true } } # No prompt mock: -Force must not reach ShouldContinue, which in a non-interactive # host like this one is an error rather than a default. Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-a' -Force -WarningAction SilentlyContinue | Out-Null } (Import-Excel -Path $script:Book -WorksheetName 'sg-a')[0].MemberName | Should -Be 'Forced' } It 'carries over groups written by an earlier run' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='sg-a'; GroupId='g1'; MemberName='Ada'; MemberType='user'; MemberId='u1'; MembershipType='Active'; AccountEnabled=$true } } Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-a' -WarningAction SilentlyContinue | Out-Null Mock Get-MsecEntraGroupMember -MockWith { [pscustomobject]@{ GroupName='sg-b'; GroupId='g2'; MemberName='Bob'; MemberType='user'; MemberId='u2'; MembershipType='Active'; AccountEnabled=$true } } Export-MsecEntraGroupMemberReport -Path $Book -Name 'sg-b' -Force -WarningAction SilentlyContinue | Out-Null } # Groups collected in separate runs share one document, so the Summary keeps both. $summary = @(Import-Excel -Path $script:Book -WorksheetName 'Summary') @($summary | ForEach-Object { $_.GroupName } | Sort-Object) | Should -Be @('sg-a', 'sg-b') } It 'requires something to look up' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) { Export-MsecEntraGroupMemberReport -Path $Book } | Should -Throw '*at least one*' } } It 'throws a clear error when not connected' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) $script:MsecSession = $null { Export-MsecEntraGroupMemberReport -Path $Book -Name 'x' } | Should -Throw '*Connect-Msec*' } } } |