tests/Export-MsecEntraDisabledUserReport.Tests.ps1

#Requires -Module Pester
#
# Tests for Export-MsecEntraDisabledUserReport.
#
# The shared writing machinery is exercised by the VM report tests. What is specific here is
# the age bucket, and the thing that is easy to get wrong:
#
# An account whose disable event has aged out of the audit log carries a BRACKET, not a date.
# The bracket can still place it in a bucket when both ends fall inside one - 'at least 30, at
# most 60' is squarely 30-to-90 - but where it straddles a boundary the honest answer is
# Unknown. What it must never be is 'Under 30 days': anything the audit log cannot see is
# OLDER than the window, never newer.

$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-MsecEntraDisabledUserReport' -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-du-$([guid]::NewGuid().Guid).xlsx"
    }
    AfterEach {
        if ($script:Book -and (Test-Path $script:Book)) { Remove-Item $script:Book -Force -ErrorAction SilentlyContinue }
    }

    It 'buckets by age, and never calls an unknown duration recent' {
        $rows = InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ id = 'tenant-1'; displayName = 'Contoso' }) }
            }
            Mock Get-MsecEntraDisabledUser -MockWith {
                [pscustomobject]@{ UserPrincipalName = 'recent@x.com';  DisabledDays = 5;    LicenseCount = 0 }
                [pscustomobject]@{ UserPrincipalName = 'twomonth@x.com'; DisabledDays = 60;   LicenseCount = 2 }
                [pscustomobject]@{ UserPrincipalName = 'halfyear@x.com'; DisabledDays = 200;  LicenseCount = 0 }
                [pscustomobject]@{ UserPrincipalName = 'ancient@x.com';  DisabledDays = 900;  LicenseCount = 1 }
                # Beyond retention, and the bracket lands wholly inside one bucket.
                [pscustomobject]@{ UserPrincipalName = 'bracketed@x.com'; DisabledDays = $null
                                   DisabledAtLeastDays = 30; DisabledAtMostDays = 60; LicenseCount = 0 }
                # Beyond retention, and the bracket straddles a boundary - genuinely unknown.
                [pscustomobject]@{ UserPrincipalName = 'vague@x.com'; DisabledDays = $null
                                   DisabledAtLeastDays = 30; DisabledAtMostDays = 400; LicenseCount = 3 }
                # Beyond retention with no successful sign-in at all: no upper bound either.
                [pscustomobject]@{ UserPrincipalName = 'silent@x.com'; DisabledDays = $null
                                   DisabledAtLeastDays = 30; DisabledAtMostDays = $null; LicenseCount = 0 }
            }

            Export-MsecEntraDisabledUserReport -Path $Book -PassThru -WarningAction SilentlyContinue
        }

        $byUpn = @{}; foreach ($r in $rows) { $byUpn[$r.UserPrincipalName] = $r }
        $byUpn['recent@x.com'].DisabledFor    | Should -Be 'Under 30 days'
        $byUpn['twomonth@x.com'].DisabledFor  | Should -Be '30 to 90 days'
        $byUpn['halfyear@x.com'].DisabledFor  | Should -Be '90 to 365 days'
        $byUpn['ancient@x.com'].DisabledFor   | Should -Be 'Over 365 days'

        # The bracket places it when both ends agree...
        $byUpn['bracketed@x.com'].DisabledFor | Should -Be '30 to 90 days'
        # ...and does not when it straddles.
        $byUpn['vague@x.com'].DisabledFor     | Should -Be 'Unknown'
        $byUpn['silent@x.com'].DisabledFor    | Should -Be 'Unknown'

        # The one reading that is certainly wrong: nothing beyond the audit window can be
        # newer than it.
        foreach ($upn in 'bracketed@x.com', 'vague@x.com', 'silent@x.com') {
            $byUpn[$upn].DisabledFor | Should -Not -Be 'Under 30 days'
        }
    }

    It 'counts accounts and licensed accounts separately per bucket' {
        $summary = InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecEntraDisabledUser -MockWith {
                [pscustomobject]@{ UserPrincipalName = 'a@x.com'; DisabledDays = 400; LicenseCount = 2 }
                [pscustomobject]@{ UserPrincipalName = 'b@x.com'; DisabledDays = 500; LicenseCount = 1 }
                [pscustomobject]@{ UserPrincipalName = 'c@x.com'; DisabledDays = 600; LicenseCount = 0 }
                [pscustomobject]@{ UserPrincipalName = 'd@x.com'; DisabledDays = 10;  LicenseCount = 0 }
            }
            Export-MsecEntraDisabledUserReport -Path $Book -WarningAction SilentlyContinue | Out-Null
            @(Import-Excel -Path $Book -WorksheetName 'Summary')
        }

        # Every bucket present, so two runs' charts stay comparable.
        @($summary).Count | Should -Be 5
        ($summary | Where-Object DisabledFor -eq 'Over 365 days').Accounts | Should -Be 3
        # Two of those three still cost money - the second series, and the finding.
        ($summary | Where-Object DisabledFor -eq 'Over 365 days').Licensed | Should -Be 2
        ($summary | Where-Object DisabledFor -eq 'Under 30 days').Accounts | Should -Be 1
        ($summary | Where-Object DisabledFor -eq 'Under 30 days').Licensed | Should -Be 0
        # Empty buckets are 0, not the phantom 1 that @($null).Count gives.
        ($summary | Where-Object DisabledFor -eq '30 to 90 days').Accounts | Should -Be 0
        (($summary | Measure-Object -Property Accounts -Sum).Sum)          | Should -Be 4
    }

    It 'charts both series against the tenant, on a sheet named after it' {
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso Ltd' }) }
            }
            Mock Get-MsecEntraDisabledUser -MockWith {
                [pscustomobject]@{ UserPrincipalName = 'a@x.com'; DisabledDays = 400; LicenseCount = 2 }
            }
            Export-MsecEntraDisabledUserReport -Path $Book -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 'Contoso Ltd'      # tenant display name, not the GUID
            $names | Should -Contain 'Summary'

            $chart = $package.Workbook.Worksheets['Dashboard'].Drawings |
                Where-Object Name -eq 'chartContoso Ltd'
            @($chart.Series).Count | Should -Be 2
            @($chart.Series | ForEach-Object { $_.Header }) | Should -Be @('Accounts', 'Licensed')
        }
        finally { Close-ExcelPackage $package -NoSave }
    }

    It 'falls back to the tenant id when the display name cannot be read' {
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith { throw 'Forbidden' }
            Mock Get-MsecEntraDisabledUser -MockWith {
                [pscustomobject]@{ UserPrincipalName = 'a@x.com'; DisabledDays = 10; LicenseCount = 0 }
            }
            Export-MsecEntraDisabledUserReport -Path $Book -WarningAction SilentlyContinue | Out-Null
        }

        # Ugly but unambiguous, and the report still lands.
        @(Open-ExcelPackage -Path $script:Book | ForEach-Object { $_.Workbook.Worksheets.Name }) |
            Should -Contain 'tenant-1'
    }

    It 'sorts the expensive, long-dead accounts to the top' {
        $order = InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecEntraDisabledUser -MockWith {
                [pscustomobject]@{ UserPrincipalName = 'fresh@x.com';    DisabledDays = 5;   LicenseCount = 5 }
                [pscustomobject]@{ UserPrincipalName = 'old-free@x.com'; DisabledDays = 900; LicenseCount = 0 }
                [pscustomobject]@{ UserPrincipalName = 'old-paid@x.com'; DisabledDays = 900; LicenseCount = 3 }
            }
            @((Export-MsecEntraDisabledUserReport -Path $Book -PassThru -WarningAction SilentlyContinue).UserPrincipalName)
        }

        # Oldest bucket first; within it, the ones still costing money ahead of the free ones.
        $order | Should -Be @('old-paid@x.com', 'old-free@x.com', 'fresh@x.com')
    }

    It 'asks before replacing a sheet that already holds evidence, and honours no' {
        # The mistyped-path guard. A snapshot report REPLACES, so pointing it at last month's
        # file destroys last month's evidence - and the old behaviour did that silently.
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecEntraDisabledUser -MockWith {
                [pscustomobject]@{ UserPrincipalName = 'a@x.com'; DisabledDays = 400; LicenseCount = 2 }
                [pscustomobject]@{ UserPrincipalName = 'b@x.com'; DisabledDays = 500; LicenseCount = 0 }
            }
            Export-MsecEntraDisabledUserReport -Path $Book -WarningAction SilentlyContinue | Out-Null
        }
        @(Import-Excel -Path $script:Book -WorksheetName 'Contoso').Count | Should -Be 2

        # Second run against the same path, answering NO.
        $rows = InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            # Would have produced a single row, wiping the two already there.
            Mock Get-MsecEntraDisabledUser -MockWith {
                [pscustomobject]@{ UserPrincipalName = 'c@x.com'; DisabledDays = 10; LicenseCount = 0 }
            }
            Mock Confirm-MsecEvidenceOverwrite -MockWith { $false }
            Export-MsecEntraDisabledUserReport -Path $Book -PassThru -WarningAction SilentlyContinue
        }

        # Nothing written, nothing emitted, and the earlier evidence intact.
        @($rows).Count | Should -Be 0
        $kept = @(Import-Excel -Path $script:Book -WorksheetName 'Contoso')
        @($kept).Count | Should -Be 2
        @($kept | ForEach-Object { $_.UserPrincipalName }) | Should -Not -Contain 'c@x.com'
    }

    It 'does not collect anything when the overwrite is declined' {
        # Declining must cost nothing. Asking after the collection would mean a full directory
        # enumeration - and on the VM reports, Run Commands against live machines - thrown away.
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecEntraDisabledUser -MockWith { [pscustomobject]@{ UserPrincipalName = 'a@x.com'; DisabledDays = 5; LicenseCount = 0 } }
            Mock Confirm-MsecEvidenceOverwrite -MockWith { $false }

            Export-MsecEntraDisabledUserReport -Path $Book -WarningAction SilentlyContinue | Out-Null

            Should -Invoke Get-MsecEntraDisabledUser -Times 0 -Exactly
        }
    }

    It 'does not ask when nothing would be lost' {
        # A new file, and a new tenant inside an existing file, are not questions worth
        # interrupting for - only a genuine replace is.
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            $resolved = Resolve-MsecEvidenceSheet -Path $Book -OwnerName 'Contoso' -OwnerId 't1' -OwnerColumn 'TenantId'
            $resolved.IsReplace | Should -BeFalse       # file does not exist yet

            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecEntraDisabledUser -MockWith { [pscustomobject]@{ UserPrincipalName = 'a@x.com'; DisabledDays = 5; LicenseCount = 0 } }
            Export-MsecEntraDisabledUserReport -Path $Book -WarningAction SilentlyContinue | Out-Null

            # Now the sheet exists and holds this tenant's rows - that IS a replace.
            $again = Resolve-MsecEvidenceSheet -Path $Book -OwnerName 'Contoso' -OwnerId 'tenant-1' -OwnerColumn 'TenantId'
            $again.IsReplace | Should -BeTrue
            $again.RowCount  | Should -Be 1

            # A DIFFERENT tenant whose name lands on the same sheet is a collision, not a
            # replace: it gets its own suffixed sheet and nothing is overwritten.
            $other = Resolve-MsecEvidenceSheet -Path $Book -OwnerName 'Contoso' -OwnerId 'tenant-2' -OwnerColumn 'TenantId'
            $other.IsReplace    | Should -BeFalse
            $other.CollidedWith | Should -Be 'tenant-1'
            $other.SheetName    | Should -Not -Be 'Contoso'
        }
    }

    It '-Force replaces without asking' {
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecEntraDisabledUser -MockWith { [pscustomobject]@{ UserPrincipalName = 'a@x.com'; DisabledDays = 400; LicenseCount = 0 } }
            Export-MsecEntraDisabledUserReport -Path $Book -WarningAction SilentlyContinue | Out-Null

            Mock Get-MsecEntraDisabledUser -MockWith {
                [pscustomobject]@{ UserPrincipalName = 'b@x.com'; DisabledDays = 500; LicenseCount = 0 }
                [pscustomobject]@{ UserPrincipalName = 'c@x.com'; DisabledDays = 600; LicenseCount = 0 }
            }
            # No prompt mock at all: -Force must not reach ShouldContinue, which would hang or
            # throw in a non-interactive host like this one.
            Export-MsecEntraDisabledUserReport -Path $Book -Force -WarningAction SilentlyContinue | Out-Null
        }

        $rows = @(Import-Excel -Path $script:Book -WorksheetName 'Contoso')
        @($rows).Count | Should -Be 2
        @($rows | ForEach-Object { $_.UserPrincipalName }) | Should -Not -Contain 'a@x.com'
    }

    It 'throws a clear error when not connected' {
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            $script:MsecSession = $null
            { Export-MsecEntraDisabledUserReport -Path $Book } | Should -Throw '*Connect-Msec*'
        }
    }
}