Tests/Admin-MorningBrief.Tests.ps1

#Requires -Modules Pester

<#
    Pester v5 tests for Admin-MorningBrief module.
    Run with: Invoke-Pester -Path .\Tests\Admin-MorningBrief.Tests.ps1 -Output Detailed
#>


BeforeAll {
    $modulePath = Join-Path $PSScriptRoot '..' 'Admin-MorningBrief.psd1'
    Import-Module $modulePath -Force -ErrorAction Stop
}

AfterAll {
    Remove-Module Admin-MorningBrief -Force -ErrorAction SilentlyContinue
}

# ═══════════════════════════════════════════════════════════════════════
# Module-level tests
# ═══════════════════════════════════════════════════════════════════════
Describe 'Module: Admin-MorningBrief' {

    Context 'Module loading' {
        It 'Should import without errors' {
            { Import-Module $modulePath -Force } | Should -Not -Throw
        }

        It 'Should export exactly 5 public functions' {
            $mod = Get-Module Admin-MorningBrief
            $mod.ExportedFunctions.Count | Should -Be 5
        }

        It 'Should export Invoke-MorningBrief' {
            (Get-Command Invoke-MorningBrief -Module Admin-MorningBrief) | Should -Not -BeNullOrEmpty
        }

        It 'Should export Get-AccountAlerts' {
            (Get-Command Get-AccountAlerts -Module Admin-MorningBrief) | Should -Not -BeNullOrEmpty
        }

        It 'Should export Get-InfrastructureAlerts' {
            (Get-Command Get-InfrastructureAlerts -Module Admin-MorningBrief) | Should -Not -BeNullOrEmpty
        }

        It 'Should export Get-SecurityAlerts' {
            (Get-Command Get-SecurityAlerts -Module Admin-MorningBrief) | Should -Not -BeNullOrEmpty
        }

        It 'Should export Get-ExpirationAlerts' {
            (Get-Command Get-ExpirationAlerts -Module Admin-MorningBrief) | Should -Not -BeNullOrEmpty
        }

        It 'Should NOT export private function Get-AlertPriority' {
            { Get-Command Get-AlertPriority -Module Admin-MorningBrief -ErrorAction Stop } |
                Should -Throw
        }

        It 'Should NOT export private function New-HtmlDashboard' {
            { Get-Command New-HtmlDashboard -Module Admin-MorningBrief -ErrorAction Stop } |
                Should -Throw
        }
    }

    Context 'Manifest validation' {
        $manifest = Test-ModuleManifest -Path $modulePath -ErrorAction Stop

        It 'Should have a valid manifest' {
            $manifest | Should -Not -BeNullOrEmpty
        }

        It 'Should have GUID f6a7b8c9-3d24-4fa0-e1b2-7d8e9f0a1b23' {
            $manifest.GUID | Should -Be 'f6a7b8c9-3d24-4fa0-e1b2-7d8e9f0a1b23'
        }

        It 'Should require PowerShell 5.1' {
            $manifest.PowerShellVersion | Should -Be '5.1'
        }

        It 'Should have Author set to Larry Roberts' {
            $manifest.Author | Should -BeLike '*Larry Roberts*'
        }

        It 'Should have a description' {
            $manifest.Description | Should -Not -BeNullOrEmpty
        }

        It 'Should have a ProjectUri' {
            $manifest.PrivateData.PSData.ProjectUri | Should -Not -BeNullOrEmpty
        }

        It 'Should have tags' {
            $manifest.PrivateData.PSData.Tags | Should -Not -BeNullOrEmpty
            $manifest.PrivateData.PSData.Tags | Should -Contain 'Admin'
            $manifest.PrivateData.PSData.Tags | Should -Contain 'Dashboard'
        }
    }
}

# ═══════════════════════════════════════════════════════════════════════
# Parameter validation tests
# ═══════════════════════════════════════════════════════════════════════
Describe 'Parameter validation' {

    Context 'Get-AccountAlerts parameters' {
        It 'Should accept -DaysPasswordExpiry' {
            (Get-Command Get-AccountAlerts).Parameters['DaysPasswordExpiry'] | Should -Not -BeNullOrEmpty
        }

        It 'Should accept -SearchBase' {
            (Get-Command Get-AccountAlerts).Parameters['SearchBase'] | Should -Not -BeNullOrEmpty
        }

        It 'Should default DaysPasswordExpiry to 14' {
            (Get-Command Get-AccountAlerts).Parameters['DaysPasswordExpiry'].Attributes |
                Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } |
                ForEach-Object { $_.Mandatory } | Should -BeFalse
        }
    }

    Context 'Get-InfrastructureAlerts parameters' {
        It 'Should require -ComputerName' {
            $param = (Get-Command Get-InfrastructureAlerts).Parameters['ComputerName']
            $mandatory = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] }
            $mandatory.Mandatory | Should -BeTrue
        }

        It 'Should accept -DiskThresholdPercent' {
            (Get-Command Get-InfrastructureAlerts).Parameters['DiskThresholdPercent'] | Should -Not -BeNullOrEmpty
        }

        It 'Should accept -UptimeThresholdDays' {
            (Get-Command Get-InfrastructureAlerts).Parameters['UptimeThresholdDays'] | Should -Not -BeNullOrEmpty
        }
    }

    Context 'Get-SecurityAlerts parameters' {
        It 'Should accept -HoursBack' {
            (Get-Command Get-SecurityAlerts).Parameters['HoursBack'] | Should -Not -BeNullOrEmpty
        }

        It 'Should accept -FailedLoginThreshold' {
            (Get-Command Get-SecurityAlerts).Parameters['FailedLoginThreshold'] | Should -Not -BeNullOrEmpty
        }

        It 'Should accept -IncludeM365 switch' {
            $param = (Get-Command Get-SecurityAlerts).Parameters['IncludeM365']
            $param | Should -Not -BeNullOrEmpty
            $param.ParameterType.Name | Should -Be 'SwitchParameter'
        }
    }

    Context 'Get-ExpirationAlerts parameters' {
        It 'Should accept -DaysCertExpiry' {
            (Get-Command Get-ExpirationAlerts).Parameters['DaysCertExpiry'] | Should -Not -BeNullOrEmpty
        }

        It 'Should accept -DaysPasswordExpiry' {
            (Get-Command Get-ExpirationAlerts).Parameters['DaysPasswordExpiry'] | Should -Not -BeNullOrEmpty
        }

        It 'Should accept -DaysAccountExpiry' {
            (Get-Command Get-ExpirationAlerts).Parameters['DaysAccountExpiry'] | Should -Not -BeNullOrEmpty
        }

        It 'Should accept -ComputerName' {
            (Get-Command Get-ExpirationAlerts).Parameters['ComputerName'] | Should -Not -BeNullOrEmpty
        }
    }

    Context 'Invoke-MorningBrief parameters' {
        It 'Should accept -OutputPath' {
            (Get-Command Invoke-MorningBrief).Parameters['OutputPath'] | Should -Not -BeNullOrEmpty
        }

        It 'Should accept -SendEmail switch' {
            $param = (Get-Command Invoke-MorningBrief).Parameters['SendEmail']
            $param.ParameterType.Name | Should -Be 'SwitchParameter'
        }

        It 'Should accept -SmtpServer' {
            (Get-Command Invoke-MorningBrief).Parameters['SmtpServer'] | Should -Not -BeNullOrEmpty
        }

        It 'Should accept -IncludeM365 switch' {
            $param = (Get-Command Invoke-MorningBrief).Parameters['IncludeM365']
            $param.ParameterType.Name | Should -Be 'SwitchParameter'
        }

        It 'Should accept -AutoRefreshSeconds' {
            (Get-Command Invoke-MorningBrief).Parameters['AutoRefreshSeconds'] | Should -Not -BeNullOrEmpty
        }
    }
}

# ═══════════════════════════════════════════════════════════════════════
# Mock-based functional tests
# ═══════════════════════════════════════════════════════════════════════
Describe 'Get-AccountAlerts (mocked)' {

    BeforeAll {
        # Re-import so internal functions are available in the module scope
        Import-Module (Join-Path $PSScriptRoot '..' 'Admin-MorningBrief.psd1') -Force
    }

    It 'Should return LockedAccount alert when Search-ADAccount finds locked users' {
        # Mock inside the module scope
        InModuleScope Admin-MorningBrief {
            Mock Search-ADAccount {
                [PSCustomObject]@{
                    SamAccountName = 'jsmith'
                    DistinguishedName = 'CN=John Smith,OU=Users,DC=corp,DC=com'
                }
            }
            Mock Get-ADUser {
                [PSCustomObject]@{
                    SamAccountName = 'jsmith'
                    DisplayName    = 'John Smith'
                    LockedOut      = $true
                    lockoutTime    = ([datetime]'2026-02-16 07:15:00').ToFileTime()
                    Enabled        = $true
                }
            }
            Mock Get-ADDefaultDomainPasswordPolicy {
                [PSCustomObject]@{ MaxPasswordAge = New-TimeSpan -Days 90 }
            }

            $results = Get-AccountAlerts -DaysPasswordExpiry 14
            $locked  = @($results | Where-Object AlertType -eq 'LockedAccount')
            $locked.Count | Should -BeGreaterOrEqual 1
            $locked[0].Priority | Should -Be 'Critical'
            $locked[0].AffectedObject | Should -BeLike '*jsmith*'
        }
    }

    It 'Should return PasswordExpiring alert as HIGH when expiry is within 3 days' {
        InModuleScope Admin-MorningBrief {
            Mock Search-ADAccount { @() }
            Mock Get-ADDefaultDomainPasswordPolicy {
                [PSCustomObject]@{ MaxPasswordAge = New-TimeSpan -Days 90 }
            }
            Mock Get-ADUser {
                param($Filter, $Properties)
                # Only respond to the password-expiry query (Enabled filter)
                if ($Filter -and $Filter.ToString() -match 'Enabled') {
                    [PSCustomObject]@{
                        SamAccountName   = 'alee'
                        DisplayName      = 'Alice Lee'
                        PasswordLastSet  = (Get-Date).AddDays(-88)   # 2 days left
                        Enabled          = $true
                        PasswordNeverExpires = $false
                    }
                }
            }

            $results  = Get-AccountAlerts -DaysPasswordExpiry 14
            $pwAlerts = @($results | Where-Object AlertType -eq 'PasswordExpiring')
            $pwAlerts.Count | Should -BeGreaterOrEqual 1
            $pwAlerts[0].Priority | Should -Be 'High'
        }
    }
}

Describe 'Get-InfrastructureAlerts (mocked)' {

    It 'Should flag a critical disk when usage exceeds 95%' {
        InModuleScope Admin-MorningBrief {
            Mock New-CimSession { [PSCustomObject]@{ Id = 1 } }
            Mock Remove-CimSession {}
            Mock Get-CimInstance {
                param($CimSession, $ClassName, $Filter)
                if ($ClassName -eq 'Win32_LogicalDisk') {
                    [PSCustomObject]@{
                        DeviceID  = 'C:'
                        Size      = 100GB
                        FreeSpace = 3GB    # 97% used
                        DriveType = 3
                    }
                }
                elseif ($ClassName -eq 'Win32_Service') {
                    @()   # no stopped services
                }
                elseif ($ClassName -eq 'Win32_OperatingSystem') {
                    [PSCustomObject]@{ LastBootUpTime = (Get-Date).AddDays(-10) }
                }
            }
            Mock Invoke-Command { $false }  # no pending reboot

            $results = Get-InfrastructureAlerts -ComputerName 'SQL02'
            $disk    = @($results | Where-Object AlertType -eq 'DiskSpaceCritical')
            $disk.Count | Should -Be 1
            $disk[0].Priority | Should -Be 'Critical'
            $disk[0].AffectedObject | Should -BeLike '*SQL02*C:*'
        }
    }

    It 'Should flag a stopped critical service' {
        InModuleScope Admin-MorningBrief {
            Mock New-CimSession { [PSCustomObject]@{ Id = 1 } }
            Mock Remove-CimSession {}
            Mock Get-CimInstance {
                param($CimSession, $ClassName, $Filter)
                if ($ClassName -eq 'Win32_LogicalDisk') { @() }
                elseif ($ClassName -eq 'Win32_Service') {
                    [PSCustomObject]@{
                        Name        = 'DNS'
                        DisplayName = 'DNS Server'
                        State       = 'Stopped'
                        StartMode   = 'Auto'
                    }
                }
                elseif ($ClassName -eq 'Win32_OperatingSystem') {
                    [PSCustomObject]@{ LastBootUpTime = (Get-Date).AddDays(-5) }
                }
            }
            Mock Invoke-Command { $false }

            $results = Get-InfrastructureAlerts -ComputerName 'DC03'
            $svcAlert = @($results | Where-Object AlertType -eq 'ServiceStopped')
            $svcAlert.Count | Should -Be 1
            $svcAlert[0].Priority | Should -Be 'Critical'
            $svcAlert[0].Detail | Should -BeLike '*DNS*Stopped*'
        }
    }

    It 'Should produce ServerUnreachable alert when CIM session fails' {
        InModuleScope Admin-MorningBrief {
            Mock New-CimSession { throw 'Connection refused' }

            $results = Get-InfrastructureAlerts -ComputerName 'OFFLINE01'
            $unreachable = @($results | Where-Object AlertType -eq 'ServerUnreachable')
            $unreachable.Count | Should -Be 1
            $unreachable[0].Priority | Should -Be 'Critical'
        }
    }
}

Describe 'Get-SecurityAlerts (mocked)' {

    It 'Should flag users exceeding failed login threshold' {
        InModuleScope Admin-MorningBrief {
            # Build 15 fake 4625 events for user "badactor"
            $fakeEvents = 1..15 | ForEach-Object {
                $props = @(
                    [PSCustomObject]@{ Value = '' },  # 0 - SubjectUserSid
                    [PSCustomObject]@{ Value = '' },  # 1 - SubjectUserName
                    [PSCustomObject]@{ Value = '' },  # 2 - SubjectDomainName
                    [PSCustomObject]@{ Value = '' },  # 3 - SubjectLogonId
                    [PSCustomObject]@{ Value = '' },  # 4 - TargetUserSid
                    [PSCustomObject]@{ Value = 'badactor' },  # 5 - TargetUserName
                    [PSCustomObject]@{ Value = 'CORP' },       # 6 - TargetDomainName
                    [PSCustomObject]@{ Value = '' },  # 7
                    [PSCustomObject]@{ Value = '' },  # 8
                    [PSCustomObject]@{ Value = '' },  # 9
                    [PSCustomObject]@{ Value = '' },  # 10
                    [PSCustomObject]@{ Value = '' },  # 11
                    [PSCustomObject]@{ Value = '' },  # 12
                    [PSCustomObject]@{ Value = '' },  # 13
                    [PSCustomObject]@{ Value = '' },  # 14
                    [PSCustomObject]@{ Value = '' },  # 15
                    [PSCustomObject]@{ Value = '' },  # 16
                    [PSCustomObject]@{ Value = '' },  # 17
                    [PSCustomObject]@{ Value = '' },  # 18
                    [PSCustomObject]@{ Value = '10.0.0.99' }   # 19 - IpAddress
                )
                [PSCustomObject]@{
                    Id          = 4625
                    TimeCreated = (Get-Date).AddMinutes(-$_)
                    Properties  = $props
                }
            }

            Mock Get-WinEvent { $fakeEvents }
            Mock Get-ADGroupMember { @() }  # skip admin checks

            $results = Get-SecurityAlerts -FailedLoginThreshold 10
            $failed  = @($results | Where-Object AlertType -eq 'FailedLoginsExceeded')
            $failed.Count | Should -Be 1
            $failed[0].Priority | Should -Be 'High'
            $failed[0].AffectedObject | Should -Be 'badactor'
            $failed[0].Detail | Should -BeLike '*15*failed*'
        }
    }
}

Describe 'Get-ExpirationAlerts (mocked)' {

    It 'Should flag a certificate expiring within 7 days as Critical' {
        InModuleScope Admin-MorningBrief {
            Mock Get-ChildItem {
                [PSCustomObject]@{
                    Subject      = 'CN=webapp.corp.com'
                    FriendlyName = 'WebApp SSL'
                    Thumbprint   = 'AABBCCDD11223344'
                    NotAfter     = (Get-Date).AddDays(5)
                    DnsNameList  = @('webapp.corp.com')
                }
            }
            Mock Get-ADDefaultDomainPasswordPolicy {
                [PSCustomObject]@{ MaxPasswordAge = New-TimeSpan -Days 90 }
            }
            Mock Get-ADUser { @() }
            Mock Get-ADDomain {
                [PSCustomObject]@{ DomainMode = 'Windows2016Domain'; DNSRoot = 'corp.com' }
            }
            Mock Get-ADForest {
                [PSCustomObject]@{ ForestMode = 'Windows2016Forest' }
            }

            $results  = Get-ExpirationAlerts -DaysCertExpiry 30
            $certAlert = @($results | Where-Object AlertType -eq 'CertificateExpiring')
            $certAlert.Count | Should -Be 1
            $certAlert[0].Priority | Should -Be 'Critical'
            $certAlert[0].Detail | Should -BeLike '*5 day*'
        }
    }

    It 'Should flag a certificate expiring within 14 days as High' {
        InModuleScope Admin-MorningBrief {
            Mock Get-ChildItem {
                [PSCustomObject]@{
                    Subject      = 'CN=mail.corp.com'
                    FriendlyName = 'Mail Cert'
                    Thumbprint   = 'EEFF00112233AABB'
                    NotAfter     = (Get-Date).AddDays(12)
                    DnsNameList  = @('mail.corp.com')
                }
            }
            Mock Get-ADDefaultDomainPasswordPolicy {
                [PSCustomObject]@{ MaxPasswordAge = New-TimeSpan -Days 90 }
            }
            Mock Get-ADUser { @() }
            Mock Get-ADDomain {
                [PSCustomObject]@{ DomainMode = 'Windows2016Domain'; DNSRoot = 'corp.com' }
            }
            Mock Get-ADForest {
                [PSCustomObject]@{ ForestMode = 'Windows2016Forest' }
            }

            $results  = Get-ExpirationAlerts -DaysCertExpiry 30
            $certAlert = @($results | Where-Object AlertType -eq 'CertificateExpiring')
            $certAlert.Count | Should -Be 1
            $certAlert[0].Priority | Should -Be 'High'
        }
    }
}

Describe 'Invoke-MorningBrief (mocked)' {

    It 'Should throw when -SendEmail is used without -SmtpServer' {
        { Invoke-MorningBrief -SendEmail -EmailTo 'a@b.com' -EmailFrom 'c@d.com' } |
            Should -Throw '*SmtpServer*'
    }

    It 'Should produce a summary object and HTML report' {
        InModuleScope Admin-MorningBrief {
            # Stub all alert functions to return controlled data
            Mock Get-AccountAlerts {
                @(
                    [PSCustomObject]@{
                        AlertType = 'LockedAccount'; Priority = 'Critical'; Source = 'ActiveDirectory'
                        AffectedObject = 'jsmith'; Detail = 'Locked out'; Timestamp = (Get-Date)
                        Category = 'Account'; ColorCode = '#f85149'; SortOrder = 1
                    }
                )
            }
            Mock Get-InfrastructureAlerts {
                @(
                    [PSCustomObject]@{
                        AlertType = 'DiskSpaceCritical'; Priority = 'Critical'; Source = 'Infrastructure'
                        AffectedObject = 'SQL02 (C:)'; Detail = '97% full'; Timestamp = (Get-Date)
                        Category = 'Infrastructure'; ColorCode = '#f85149'; SortOrder = 1
                    }
                )
            }
            Mock Get-SecurityAlerts { @() }
            Mock Get-ExpirationAlerts {
                @(
                    [PSCustomObject]@{
                        AlertType = 'CertificateExpiring'; Priority = 'High'; Source = 'Certificates'
                        AffectedObject = 'WEB01 - SSL'; Detail = 'Expires in 10 days'; Timestamp = (Get-Date)
                        Category = 'Expiration'; ColorCode = '#d29922'; SortOrder = 2
                    }
                )
            }

            $outputDir = Join-Path $TestDrive 'Reports'
            $result    = Invoke-MorningBrief -ComputerName 'SQL02' -OutputPath $outputDir

            $result.CriticalCount | Should -Be 2
            $result.HighCount     | Should -Be 1
            $result.TotalCount    | Should -Be 3
            $result.ReportPath    | Should -Not -BeNullOrEmpty
            Test-Path $result.ReportPath | Should -BeTrue

            # Verify HTML contains key elements
            $html = Get-Content $result.ReportPath -Raw
            $html | Should -BeLike '*Morning Brief*'
            $html | Should -BeLike '*jsmith*'
            $html | Should -BeLike '*SQL02*'
        }
    }
}

Describe 'Get-AlertPriority (private, tested via InModuleScope)' {

    It 'Should return Critical for LockedAccount' {
        InModuleScope Admin-MorningBrief {
            $p = Get-AlertPriority -AlertType 'LockedAccount'
            $p.Priority  | Should -Be 'Critical'
            $p.SortOrder | Should -Be 1
        }
    }

    It 'Should return High for PasswordExpiring with 2 days left' {
        InModuleScope Admin-MorningBrief {
            $p = Get-AlertPriority -AlertType 'PasswordExpiring' -Detail @{ DaysUntilExpiry = 2 }
            $p.Priority | Should -Be 'High'
        }
    }

    It 'Should return Medium for PasswordExpiring with 10 days left' {
        InModuleScope Admin-MorningBrief {
            $p = Get-AlertPriority -AlertType 'PasswordExpiring' -Detail @{ DaysUntilExpiry = 10 }
            $p.Priority | Should -Be 'Medium'
        }
    }

    It 'Should return Critical for CertificateExpiring with 5 days left' {
        InModuleScope Admin-MorningBrief {
            $p = Get-AlertPriority -AlertType 'CertificateExpiring' -Detail @{ DaysUntilExpiry = 5 }
            $p.Priority | Should -Be 'Critical'
        }
    }

    It 'Should return Medium for unknown alert types' {
        InModuleScope Admin-MorningBrief {
            $p = Get-AlertPriority -AlertType 'SomethingNew'
            $p.Priority | Should -Be 'Medium'
        }
    }
}