tests/Smoke.Windows.Tests.ps1

#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }

<#
.SYNOPSIS
    Real-Windows execution smoke tests for the WindowsSecurityAudit module.
.DESCRIPTION
    Unlike tests/WindowsSecurityAudit.Tests.ps1 (structural, cross-platform), this suite
    IMPORTS the module and EXECUTES its exported functions against the live system with
    safe, fast parameters. No mocks. GitHub-hosted windows-latest runners are elevated,
    so admin-only calls (fsutil, Security event log, HKLM) genuinely work there.
 
    Assertions are deliberately shape-based: an empty result set is a valid outcome on a
    clean runner (assert structure, not content); a THROW is a failure.
 
    Platform guard: the whole Describe block is skipped on non-Windows platforms, so
    `Invoke-Pester -Path ./tests` on macOS/Linux stays green (tests report as Skipped).
 
    Run standalone on Windows:
        Invoke-Pester -Path ./tests/Smoke.Windows.Tests.ps1
 
    Deliberately excluded (skipped with reasons at the bottom):
      * Test-SystemIntegrity - runs 'sfc /scannow' + DISM (10+ minutes)
      * Get-SecurityMetrics Core - transitively invokes Test-SystemIntegrity (SFC/DISM)
      * Export-MemoryDump - interactive (Read-Host)
      * Invoke-IncidentResponse, Set-SecurityBaseline, Register-ScheduledSecurityScan,
        Send-SiemEvent - destructive or require real endpoints; WhatIf-covered
                                    alternatives are exercised where supported
.NOTES
    The module targets Windows PowerShell 5.1+, so only 5.1-compatible syntax is used
    here (this file is executed under both pwsh 7 and powershell 5.1 in CI).
#>


BeforeDiscovery {
    # Platform guard: evaluate once at discovery time and skip the entire Describe on
    # non-Windows hosts. $IsWindows only exists on PowerShell 6+; on Windows PowerShell
    # 5.1 (which only ships on Windows) fall back to $env:OS.
    if ($PSVersionTable.PSVersion.Major -ge 6) {
        $script:IsWindowsHost = [bool]$IsWindows
    }
    else {
        $script:IsWindowsHost = ($env:OS -eq 'Windows_NT')
    }
}

Describe 'WindowsSecurityAudit real-Windows smoke tests' -Tag @('Smoke', 'Windows') -Skip:(-not $script:IsWindowsHost) {

    BeforeAll {
        $script:ModuleRoot = Split-Path -Parent $PSScriptRoot
        Import-Module (Join-Path -Path $script:ModuleRoot -ChildPath 'WindowsSecurityAudit.psd1') -Force -ErrorAction Stop

        # Asserts that $Object is non-null and exposes every property in $Names.
        # Property names below were verified against each function's source file.
        function Assert-ResultShape {
            param(
                [Parameter(Mandatory = $true)]
                [AllowNull()]
                [object]$Object,

                [Parameter(Mandatory = $true)]
                [string[]]$Names
            )
            $Object | Should -Not -BeNullOrEmpty
            foreach ($name in $Names) {
                $Object.PSObject.Properties.Name | Should -Contain $name
            }
        }
    }

    # ---------------------------------------------------------------- Core
    It 'Get-EventIdDescription returns a description for event 4625' {
        $result = Get-EventIdDescription -EventId 4625
        Assert-ResultShape -Object $result -Names @('EventId', 'Source', 'Category', 'Description', 'Severity', 'MITREAttack', 'SecurityImplication', 'RecommendedAction')
        $result.EventId | Should -Be 4625
        $result.Description | Should -Be 'An account failed to log on'
    }

    It 'Get-SecurityBaseline returns a baseline object' {
        $result = Get-SecurityBaseline
        Assert-ResultShape -Object $result -Names @('ComputerName', 'ScanDate', 'WindowsDefender', 'Firewall', 'UAC', 'BitLocker', 'WindowsUpdate', 'SecurityScore')
    }

    It 'Get-SystemInfo returns system information without optional switches' {
        $result = Get-SystemInfo
        Assert-ResultShape -Object $result -Names @('CollectionDate', 'ComputerSystem', 'OperatingSystem', 'Hardware', 'Network', 'Security')
    }

    # ------------------------------------------------------------ Detection
    It 'Find-SuspiciousProcesses executes and returns score-shaped objects when non-empty' {
        $result = @(Find-SuspiciousProcesses)
        # An empty result set is a valid outcome on a clean runner
        foreach ($item in $result) {
            Assert-ResultShape -Object $item -Names @('SuspicionScore')
        }
    }

    It 'Find-PersistenceMechanisms returns the persistence report object' {
        $result = Find-PersistenceMechanisms
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'RegistryRunKeys', 'StartupFolder', 'ScheduledTasks', 'Services', 'WMISubscriptions', 'TotalFindings', 'SuspiciousCount')
    }

    It 'Find-NetworkAnomalies returns the anomaly report object' {
        # Note: this function has no -Hours parameter (only -IncludeEstablished/-CheckDNS)
        $result = Find-NetworkAnomalies
        Assert-ResultShape -Object $result -Names @('ScanDate', 'SuspiciousListeners', 'SuspiciousConnections', 'UnusualPorts', 'DNSAnomalies', 'TotalAnomalies')
    }

    It 'Find-SuspiciousAuthentication -Hours 1 returns the analysis object' {
        $result = Find-SuspiciousAuthentication -Hours 1
        Assert-ResultShape -Object $result -Names @('AnalysisDate', 'TimeRange', 'FailedLogons', 'AccountLockouts', 'PrivilegeEscalations', 'BruteForceIndicators', 'Statistics')
        $result.TimeRange | Should -Be '1 hours'
    }

    It 'Get-CredentialAccessEvents -Hours 1 returns events, summary and recommendations' {
        $result = Get-CredentialAccessEvents -Hours 1
        Assert-ResultShape -Object $result -Names @('ScanDate', 'TimeRange', 'Events', 'Summary', 'Recommendations')
    }

    It 'Find-DefenseEvasion -Hours 1 returns findings with per-check status' {
        $result = Find-DefenseEvasion -Hours 1
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'TimeRange', 'EvasionFindings', 'Summary', 'CheckStatus', 'Recommendations')
    }

    It 'Get-SysmonAnalytics -Hours 1 degrades gracefully when Sysmon is absent' {
        $result = Get-SysmonAnalytics -Hours 1
        Assert-ResultShape -Object $result -Names @('ScanDate', 'TimeRange', 'Status', 'Findings', 'Summary', 'Recommendations')
        # Sysmon is not installed on GitHub-hosted runners; 'NotAvailable' must not throw
        $result.Status | Should -BeIn @('OK', 'NotAvailable')
        $result.Summary.SysmonPresent | Should -BeOfType [bool]
    }

    # ------------------------------------------------------- WindowsDefender
    It 'Get-DefenderStatus returns the Defender status object' {
        $result = Get-DefenderStatus
        Assert-ResultShape -Object $result -Names @('CollectionDate', 'ComputerName', 'ProtectionStatus', 'SignatureStatus', 'ScanHistory', 'Configuration', 'HealthStatus', 'Recommendations')
    }

    # ---------------------------------------------------------- Vulnerability
    It 'Get-VulnerabilityAssessment runs offline (no -CheckCVEs) and returns the assessment object' {
        $result = Get-VulnerabilityAssessment
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'ScanDepth', 'Software', 'Vulnerabilities', 'Summary', 'Recommendations')
    }

    It 'Get-ExposedServices returns listening-port findings' {
        $result = Get-ExposedServices
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'PublicIP', 'LocalIPs', 'ListeningPorts', 'ExposedServices', 'Summary', 'Recommendations')
    }

    It 'Get-SecurityMisconfigurations returns findings and summary' {
        $result = Get-SecurityMisconfigurations
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'OSVersion', 'Misconfigurations', 'Summary', 'Recommendations')
    }

    It 'Test-CertificateHealth returns certificate health summary' {
        $result = Test-CertificateHealth
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'TotalCertificates', 'HealthyCertificates', 'Issues', 'Summary', 'Recommendations')
    }

    It 'Test-PatchCompliance returns patch compliance summary' {
        $result = Test-PatchCompliance
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'OSVersion', 'InstalledUpdates', 'MissingUpdates', 'Summary', 'Recommendations')
    }

    It 'Find-EOLSoftware returns the EOL findings object' {
        $result = Find-EOLSoftware
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'TotalSoftware', 'EOLSoftware', 'ExpiringSoon', 'Summary', 'Recommendations')
    }

    # ------------------------------------------------------------- Compliance
    It 'Test-CISBenchmark -Level 1 returns controls and summary' {
        $result = Test-CISBenchmark -Level 1
        Assert-ResultShape -Object $result -Names @('TestDate', 'ComputerName', 'CISLevel', 'Category', 'Controls', 'Summary', 'Recommendations')
        $result.CISLevel | Should -Be 1
    }

    It 'Test-NISTCompliance returns controls and summary' {
        $result = Test-NISTCompliance
        Assert-ResultShape -Object $result -Names @('TestDate', 'ComputerName', 'NISTRevision', 'ControlFamily', 'Controls', 'Summary', 'Recommendations')
    }

    It 'Test-PCI-DSS -Version 4.0 returns requirements and summary' {
        $result = Test-PCI-DSS -Version '4.0'
        Assert-ResultShape -Object $result -Names @('TestDate', 'ComputerName', 'PCIDSSVersion', 'RequirementFilter', 'Requirements', 'Summary', 'Recommendations')
        $result.PCIDSSVersion | Should -Be '4.0'
    }

    # -------------------------------------------------------------- Analysis
    It 'Get-EventLogAnalysis -Hours 1 returns the analysis object' {
        $result = Get-EventLogAnalysis -Hours 1
        Assert-ResultShape -Object $result -Names @('AnalysisDate', 'TimeRange', 'SecurityEvents', 'SystemEvents', 'ApplicationEvents', 'CriticalErrors', 'Statistics')
    }

    # --------------------------------------------------------- ThreatHunting
    It 'Get-ThreatIntelligence matches built-in IOCs without -UpdateFeeds' {
        $result = Get-ThreatIntelligence -CheckProcesses
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'Matches', 'Summary', 'Recommendations')
    }

    It 'Find-LateralMovement returns indicators (uses -Days, not -Hours)' {
        $result = Find-LateralMovement -Days 1
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'AnalysisPeriod', 'Indicators', 'Summary')
    }

    It 'Find-LivingOffLand returns LOLBin detections object' {
        $result = Find-LivingOffLand
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'DetectedLOLBins', 'Summary', 'Recommendations')
    }

    It 'Find-APTIndicators returns indicators with threat score' {
        $result = Find-APTIndicators
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'Indicators', 'ThreatScore', 'RiskLevel', 'Summary')
    }

    It 'Find-DataExfiltration returns indicators (switch-based checks, no -Hours)' {
        $result = Find-DataExfiltration
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'ThresholdMB', 'Indicators', 'Summary')
    }

    It 'Get-MITREAttackMapping with defaults returns the mapping object' {
        $result = Get-MITREAttackMapping
        Assert-ResultShape -Object $result -Names @('AnalysisDate', 'ComputerName', 'DetectedTechniques', 'TacticBreakdown')
    }

    It 'Export-AttackNavigatorLayer exports a synthetic technique record' {
        $record = [PSCustomObject]@{
            TechniqueID   = 'T1078'
            TechniqueName = 'Valid Accounts'
            Tactic        = 'InitialAccess'
            Severity      = 'High'
        }
        $result = $record | Export-AttackNavigatorLayer -OutputPath $TestDrive -LayerName 'Smoke Test Layer'
        Assert-ResultShape -Object $result -Names @('LayerName', 'OutputPath', 'TechniqueCount', 'DetectionCount', 'NavigatorUrl')
        $result.TechniqueCount | Should -Be 1
        $result.OutputPath | Should -Exist
    }

    # -------------------------------------------------------------- Forensics
    It 'Get-USBHistory returns USB device history object' {
        $result = Get-USBHistory
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'USBDevices', 'ConnectionEvents', 'Summary')
    }

    It 'Get-ExecutionArtifacts returns the artifacts object' {
        $result = Get-ExecutionArtifacts
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'Artifacts', 'Summary')
    }

    It 'Get-ArtifactCollection -CollectionProfile Quick collects into TestDrive' {
        # Note: the profile parameter is -CollectionProfile (there is no -CollectionType)
        $outDir = Join-Path -Path $TestDrive -ChildPath 'artifacts'
        $result = Get-ArtifactCollection -CollectionProfile Quick -OutputPath $outDir
        Assert-ResultShape -Object $result -Names @('CollectionDate', 'ComputerName', 'Profile', 'OutputPath', 'ArtifactsCollected', 'Summary', 'CompressedArchive')
        $result.Profile | Should -Be 'Quick'
        $result.OutputPath | Should -Exist
    }

    It 'Get-UserActivityArtifacts returns the activity object' {
        $result = Get-UserActivityArtifacts
        Assert-ResultShape -Object $result -Names @('ScanDate', 'ComputerName', 'ProfilesAnalyzed', 'RecycleBin', 'RecentLinks', 'JumpLists', 'Timeline', 'Summary')
    }

    It 'Get-FileSystemJournal -Mode MFTSummary returns journal summary (fsutil, admin)' {
        $result = Get-FileSystemJournal -Mode MFTSummary
        Assert-ResultShape -Object $result -Names @('CollectionDate', 'ComputerName', 'DriveLetter', 'Mode', 'IsElevated', 'Records', 'Summary', 'Notes', 'Recommendations')
        $result.Mode | Should -Be 'MFTSummary'
    }

    It 'Get-FileSystemJournal -Mode USNJournal returns journal data (fsutil, admin)' {
        $result = Get-FileSystemJournal -Mode USNJournal
        Assert-ResultShape -Object $result -Names @('CollectionDate', 'ComputerName', 'DriveLetter', 'Mode', 'IsElevated', 'Records', 'Summary', 'Notes', 'Recommendations')
        $result.Mode | Should -Be 'USNJournal'
    }

    # -------------------------------------------------------------- Reporting
    It 'Get-SecurityMetrics -IncludeModules WindowsDefender returns aggregated metrics' {
        # DEVIATION from the original spec ('-IncludeModules Core'): the Core metrics
        # collector transitively invokes Test-SystemIntegrity ('sfc /scannow' + DISM,
        # 10+ minutes) and a full Invoke-SecurityAssessment, which would blow the
        # smoke-suite runtime budget. The WindowsDefender collector exercises the same
        # metrics pipeline via Get-DefenderStatus + Update-DefenderConfiguration -WhatIf.
        $result = Get-SecurityMetrics -IncludeModules WindowsDefender
        Assert-ResultShape -Object $result -Names @('Timestamp', 'Hostname', 'OverallSecurityScore', 'RiskLevel', 'Findings', 'Recommendations', 'ModulesCollected', 'WindowsDefender')
        $result.WindowsDefender.Enabled | Should -BeTrue
    }

    It 'New-ConsolidatedReport -SkipDataCollection writes a report into TestDrive' {
        $result = New-ConsolidatedReport -SkipDataCollection -OutputPath $TestDrive
        Assert-ResultShape -Object $result -Names @('Success', 'ReportPath', 'DataSource', 'SectionsCollected', 'SectionsNotCollected', 'TotalFindings', 'GenerationTime')
        $result.Success | Should -BeTrue
        $result.ReportPath | Should -Exist
    }

    # --------------------------------------------------------------- Response
    It 'ConvertTo-SiemEvent converts a synthetic finding to NDJSON and CEF' {
        $finding = [PSCustomObject]@{
            Severity       = 'High'
            Category       = 'SmokeTest'
            Finding        = 'Synthetic smoke-test finding'
            Impact         = 'None'
            Recommendation = 'No action required'
            TechniqueId    = 'T1078'
        }

        $ndjson = $finding | ConvertTo-SiemEvent -Format NDJSON
        $ndjson | Should -Not -BeNullOrEmpty
        $parsed = $ndjson | ConvertFrom-Json
        $parsed.product | Should -Be 'WindowsSecurityAudit'
        $parsed.severity | Should -Be 'High'
        $parsed.category | Should -Be 'SmokeTest'
        $parsed.technique_id | Should -Be 'T1078'

        $cef = $finding | ConvertTo-SiemEvent -Format CEF
        $cef | Should -Match '^CEF:0\|'
        $cef | Should -Match 'cs3=T1078'
    }

    It 'Export-SecurityReport -ReportType Technical -Format JSON writes a report file' {
        $result = @(Export-SecurityReport -ReportType Technical -Format JSON -NoAutoOpen -OutputPath $TestDrive)
        $result.Count | Should -BeGreaterThan 0
        foreach ($file in $result) {
            $file | Should -Exist
        }
    }

    # ------------------------------------------------------------------ Core (compare)
    It 'Compare-SecurityBaseline comparing a saved baseline to itself reports zero changes' {
        $baselinePath = Join-Path -Path $TestDrive -ChildPath 'baseline.json'
        Get-SecurityBaseline | ConvertTo-Json -Depth 6 | Out-File -FilePath $baselinePath -Encoding UTF8
        $result = Compare-SecurityBaseline -BaselinePath $baselinePath -CurrentPath $baselinePath
        Assert-ResultShape -Object $result -Names @('CompareDate', 'ComputerName', 'BaselineSource', 'CurrentSource', 'Categories', 'Changes', 'Summary')
        $result.Summary.TotalChanges | Should -Be 0
        $result.Summary.OverallTrend | Should -Be 'Stable'
    }

    # ------------------------------------------------------------- Enterprise
    It 'Invoke-SecurityAssessment -AssessmentType Basic writes SecurityAssessment.json' {
        $script:AssessmentDir = Join-Path -Path $TestDrive -ChildPath 'assess'
        $result = Invoke-SecurityAssessment -AssessmentType Basic -OutputPath $script:AssessmentDir
        Assert-ResultShape -Object $result -Names @('AssessmentDate', 'AssessmentType', 'ComputerName', 'OverallScore', 'RiskLevel', 'Categories', 'Findings', 'Recommendations', 'ComplianceChecks')
        $result.AssessmentType | Should -Be 'Basic'
        $script:AssessmentJsonPath = Join-Path -Path $script:AssessmentDir -ChildPath 'SecurityAssessment.json'
        $script:AssessmentJsonPath | Should -Exist
    }

    # ------------------------------------------------------------- Hardening
    It 'Invoke-SecurityRemediation -WhatIf against the assessment JSON changes nothing' {
        $assessmentJson = $script:AssessmentJsonPath
        if (-not $assessmentJson -or -not (Test-Path -Path $assessmentJson)) {
            # Self-heal if the assessment test did not run first
            $fallbackDir = Join-Path -Path $TestDrive -ChildPath 'assess-remediation'
            Invoke-SecurityAssessment -AssessmentType Basic -OutputPath $fallbackDir | Out-Null
            $assessmentJson = Join-Path -Path $fallbackDir -ChildPath 'SecurityAssessment.json'
        }

        $result = Invoke-SecurityRemediation -AssessmentPath $assessmentJson -GenerateRollback -WhatIf -RollbackDirectory $TestDrive
        Assert-ResultShape -Object $result -Names @('RemediationDate', 'MaxSeverity', 'WhatIf', 'FindingsProcessed', 'Remediated', 'ManualReview', 'Failed', 'RollbackPath')
        $result.WhatIf | Should -BeTrue
        # WhatIf: nothing may be applied and no rollback script may be written
        @($result.Remediated).Count | Should -Be 0
        $result.RollbackPath | Should -BeNullOrEmpty
        @(Get-ChildItem -Path $TestDrive -Filter 'SecurityRemediation_Rollback_*.ps1' -Recurse -ErrorAction SilentlyContinue).Count | Should -Be 0
    }

    It 'Update-DefenderConfiguration honors -WhatIf without changing the system' {
        { Update-DefenderConfiguration -WhatIf } | Should -Not -Throw
    }

    # ------------------------------------------------------- Skipped by design
    It 'Test-SystemIntegrity is skipped (runs sfc /scannow + DISM, 10+ minutes)' {
        Set-ItResult -Skipped -Because 'Test-SystemIntegrity runs SFC /scannow and DISM health checks (10+ minutes) - outside the smoke-test runtime budget'
    }

    It 'Export-MemoryDump is skipped (interactive Read-Host prompt)' {
        Set-ItResult -Skipped -Because 'Export-MemoryDump prompts interactively via Read-Host'
    }

    It 'Invoke-IncidentResponse is skipped (performs real response actions)' {
        Set-ItResult -Skipped -Because 'Invoke-IncidentResponse executes real incident-response actions (kills processes, disables accounts)'
    }

    It 'Register-ScheduledSecurityScan is skipped (creates a real scheduled task)' {
        Set-ItResult -Skipped -Because 'Register-ScheduledSecurityScan registers a persistent scheduled task on the runner'
    }

    It 'Send-SiemEvent is skipped (requires a real SIEM endpoint)' {
        Set-ItResult -Skipped -Because 'Send-SiemEvent posts to a live SIEM endpoint; conversion is covered via ConvertTo-SiemEvent'
    }

    It 'Set-SecurityBaseline is skipped (modifies system configuration)' {
        Set-ItResult -Skipped -Because 'Set-SecurityBaseline hardens the live system; remediation WhatIf is covered via Invoke-SecurityRemediation'
    }
}