Tests/Private/Logging.Tests.ps1

BeforeDiscovery {
    Import-Module (Join-Path $PSScriptRoot '../../Posh-SecretRotation.psd1') -Force
}

Describe 'Private/Logging' -Tag Unit {
    InModuleScope 'Posh-SecretRotation' {
        BeforeEach {
            $script:LoggingConfigWarned = $false
            $script:LoggingSinkFailed = $false
        }

        Describe 'Get-SecretRotationLoggingConfig' {
            It 'defaults Enabled to $false when Logging is absent' {
                $config = [PSCustomObject]@{ version = '1.0' }
                (Get-SecretRotationLoggingConfig -Config $config).Enabled | Should -Be $false
            }

            It 'passes through Enabled/MinimumLevel/Mode when valid' {
                $config = [PSCustomObject]@{
                    Logging = [PSCustomObject]@{ Enabled = $true; MinimumLevel = 'Debug'; Mode = 'EventLog' }
                }
                $result = Get-SecretRotationLoggingConfig -Config $config
                $result.Enabled | Should -Be $true
                $result.MinimumLevel | Should -Be 'Debug'
                $result.Mode | Should -Be 'EventLog'
            }

            It 'defaults every File/EventLog field when absent' {
                $config = [PSCustomObject]@{ Logging = [PSCustomObject]@{ Enabled = $true } }
                $result = Get-SecretRotationLoggingConfig -Config $config
                $result.File.Path | Should -Be '%TEMP%\Posh-SecretRotation'
                $result.File.FileName | Should -Be 'Posh-SecretRotation-{yyyyMMdd}.log'
                $result.EventLog.LogName | Should -Be 'Application'
                $result.EventLog.Source | Should -Be 'Posh-SecretRotation'
                $result.EventLog.EventIdInformation | Should -Be 1000
                $result.EventLog.EventIdWarning | Should -Be 2000
                $result.EventLog.EventIdError | Should -Be 3000
            }

            It 'falls back to Information and warns once on an invalid MinimumLevel' {
                Mock Write-Warning {}
                $config = [PSCustomObject]@{ Logging = [PSCustomObject]@{ MinimumLevel = 'Bogus' } }

                (Get-SecretRotationLoggingConfig -Config $config).MinimumLevel | Should -Be 'Information'
                Get-SecretRotationLoggingConfig -Config $config | Out-Null

                Should -Invoke Write-Warning -Times 1 -Exactly
            }

            It 'falls back to File and warns once on an invalid Mode' {
                Mock Write-Warning {}
                $config = [PSCustomObject]@{ Logging = [PSCustomObject]@{ Mode = 'Bogus' } }

                (Get-SecretRotationLoggingConfig -Config $config).Mode | Should -Be 'File'
                Get-SecretRotationLoggingConfig -Config $config | Out-Null

                Should -Invoke Write-Warning -Times 1 -Exactly
            }
        }

        Describe 'Resolve-SecretRotationLogFilePath' {
            BeforeAll {
                $script:logDir = Join-Path ([System.IO.Path]::GetTempPath()) "SecretRotationLogTest-$([guid]::NewGuid())"
            }

            AfterAll {
                Remove-Item -Path $script:logDir -Recurse -Force -ErrorAction SilentlyContinue
            }

            It 'expands the path, expands the date token, and creates the directory' {
                $file = [PSCustomObject]@{ Path = $script:logDir; FileName = 'test-{yyyyMMdd}.log' }
                $expectedName = "test-$((Get-Date).ToString('yyyyMMdd')).log"

                $result = Resolve-SecretRotationLogFilePath -File $file

                $result | Should -Be (Join-Path $script:logDir $expectedName)
                Test-Path -Path $script:logDir | Should -Be $true
            }
        }

        Describe 'Write-SecretRotationFileLogEntry' {
            BeforeAll {
                $script:logFile = Join-Path ([System.IO.Path]::GetTempPath()) "SecretRotationFileLogTest-$([guid]::NewGuid()).log"
            }

            AfterAll {
                Remove-Item -Path $script:logFile -Force -ErrorAction SilentlyContinue
            }

            It 'appends a formatted line containing level, user, cmdlet, target, and message' {
                Mock Resolve-SecretRotationLogFilePath { $script:logFile }

                $entry = [PSCustomObject]@{
                    Timestamp  = Get-Date
                    Level      = 'Information'
                    UserName   = 'alice'
                    CmdletName = 'Update-SecretRotationAccountPassword'
                    TargetName = 'corp-ad-svcaccount1'
                    Message    = 'Password rotated'
                }

                Write-SecretRotationFileLogEntry -File ([PSCustomObject]@{ Path = 'unused'; FileName = 'unused' }) -Entry $entry

                $content = Get-Content -Path $script:logFile -Raw
                $content | Should -BeLike '*[INFORMATION]*'
                $content | Should -BeLike '*alice*'
                $content | Should -BeLike '*Update-SecretRotationAccountPassword*'
                $content | Should -BeLike '*Target=corp-ad-svcaccount1*'
                $content | Should -BeLike '*Password rotated*'
            }
        }

        Describe 'Test-SecretRotationEventSourceExists' {
            It 'returns a boolean, or is skipped when this session cannot search all local event logs' {
                # [System.Diagnostics.EventLog]::SourceExists() searches every local log and
                # throws (rather than returning $false) when one of them - typically "Security" -
                # is inaccessible to a non-elevated session. Confirmed in this dev environment:
                # a non-elevated call throws even for a source name that doesn't exist anywhere.
                # That is an environment/elevation limitation, not a defect in this thin wrapper.
                try {
                    $result = Test-SecretRotationEventSourceExists -Source 'Posh-SecretRotation-Nonexistent-Test-Source'
                    $result | Should -BeOfType [bool]
                } catch {
                    if ($_.Exception.Message -like '*Inaccessible logs*') {
                        Set-ItResult -Skipped -Because 'SourceExists() requires an elevated session to search all local event logs in this environment'
                    } else {
                        throw
                    }
                }
            }
        }

        Describe 'Write-SecretRotationEventLogEntry' {
            It 'warns once and no-ops when Write-EventLog is unavailable on this platform' {
                Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Write-EventLog' }
                Mock Write-Warning {}
                Mock New-EventLog {}

                $eventLog = [PSCustomObject]@{ LogName = 'Application'; Source = 'Posh-SecretRotation'; EventIdInformation = 1000; EventIdWarning = 2000; EventIdError = 3000 }
                $entry = [PSCustomObject]@{ Timestamp = Get-Date; Level = 'Information'; UserName = 'alice'; CmdletName = 'Test'; TargetName = $null; Message = 'msg' }

                { Write-SecretRotationEventLogEntry -EventLog $eventLog -Entry $entry } | Should -Not -Throw

                Should -Invoke Write-Warning -Times 1 -Exactly
                Should -Invoke New-EventLog -Times 0 -Exactly
            }

            It 'creates the event source when missing, then writes the entry' {
                Mock Test-SecretRotationEventSourceExists { $false }
                Mock New-EventLog {}
                Mock Write-EventLog {}

                $eventLog = [PSCustomObject]@{ LogName = 'Application'; Source = 'Posh-SecretRotation'; EventIdInformation = 1000; EventIdWarning = 2000; EventIdError = 3000 }
                $entry = [PSCustomObject]@{ Timestamp = Get-Date; Level = 'Error'; UserName = 'alice'; CmdletName = 'Test'; TargetName = 'corp-ad-svcaccount1'; Message = 'boom' }

                Write-SecretRotationEventLogEntry -EventLog $eventLog -Entry $entry

                Should -Invoke New-EventLog -Times 1 -Exactly
                Should -Invoke Write-EventLog -Times 1 -Exactly -ParameterFilter {
                    $EventId -eq 3000 -and $EntryType -eq [System.Diagnostics.EventLogEntryType]::Error
                }
            }

            It 'never throws when the underlying Event Log call fails' {
                Mock Test-SecretRotationEventSourceExists { $true }
                Mock Write-EventLog { throw 'access denied' }
                Mock Write-Warning {}

                $eventLog = [PSCustomObject]@{ LogName = 'Application'; Source = 'Posh-SecretRotation'; EventIdInformation = 1000; EventIdWarning = 2000; EventIdError = 3000 }
                $entry = [PSCustomObject]@{ Timestamp = Get-Date; Level = 'Information'; UserName = 'alice'; CmdletName = 'Test'; TargetName = $null; Message = 'msg' }

                { Write-SecretRotationEventLogEntry -EventLog $eventLog -Entry $entry } | Should -Not -Throw
                Should -Invoke Write-Warning -Times 1 -Exactly
            }
        }

        Describe 'Write-SecretRotationLog' {
            It 'no-ops when logging is disabled' {
                Mock Get-SecretRotationLoggingConfig { [PSCustomObject]@{ Enabled = $false; MinimumLevel = 'Debug'; Mode = 'File'; File = $null; EventLog = $null } }
                Mock Write-SecretRotationFileLogEntry {}
                Mock Write-SecretRotationEventLogEntry {}

                Write-SecretRotationLog -Config ([PSCustomObject]@{}) -Level Information -Message 'msg' -CmdletName 'Test'

                Should -Invoke Write-SecretRotationFileLogEntry -Times 0 -Exactly
                Should -Invoke Write-SecretRotationEventLogEntry -Times 0 -Exactly
            }

            It 'filters out entries below MinimumLevel' {
                Mock Get-SecretRotationLoggingConfig { [PSCustomObject]@{ Enabled = $true; MinimumLevel = 'Warning'; Mode = 'File'; File = [PSCustomObject]@{}; EventLog = [PSCustomObject]@{} } }
                Mock Write-SecretRotationFileLogEntry {}

                Write-SecretRotationLog -Config ([PSCustomObject]@{}) -Level Information -Message 'msg' -CmdletName 'Test'
                Should -Invoke Write-SecretRotationFileLogEntry -Times 0 -Exactly

                Write-SecretRotationLog -Config ([PSCustomObject]@{}) -Level Error -Message 'msg' -CmdletName 'Test'
                Should -Invoke Write-SecretRotationFileLogEntry -Times 1 -Exactly
            }

            It 'dispatches to the File sink when Mode is File' {
                Mock Get-SecretRotationLoggingConfig { [PSCustomObject]@{ Enabled = $true; MinimumLevel = 'Debug'; Mode = 'File'; File = [PSCustomObject]@{}; EventLog = [PSCustomObject]@{} } }
                Mock Write-SecretRotationFileLogEntry {}
                Mock Write-SecretRotationEventLogEntry {}

                Write-SecretRotationLog -Config ([PSCustomObject]@{}) -Level Information -Message 'msg' -CmdletName 'Test'

                Should -Invoke Write-SecretRotationFileLogEntry -Times 1 -Exactly
                Should -Invoke Write-SecretRotationEventLogEntry -Times 0 -Exactly
            }

            It 'dispatches to the EventLog sink when Mode is EventLog' {
                Mock Get-SecretRotationLoggingConfig { [PSCustomObject]@{ Enabled = $true; MinimumLevel = 'Debug'; Mode = 'EventLog'; File = [PSCustomObject]@{}; EventLog = [PSCustomObject]@{} } }
                Mock Write-SecretRotationFileLogEntry {}
                Mock Write-SecretRotationEventLogEntry {}

                Write-SecretRotationLog -Config ([PSCustomObject]@{}) -Level Information -Message 'msg' -CmdletName 'Test'

                Should -Invoke Write-SecretRotationEventLogEntry -Times 1 -Exactly
                Should -Invoke Write-SecretRotationFileLogEntry -Times 0 -Exactly
            }

            It 'redacts Password/Secret/Token/Credential/APIKey-named bound parameters but keeps others' {
                Mock Get-SecretRotationLoggingConfig { [PSCustomObject]@{ Enabled = $true; MinimumLevel = 'Debug'; Mode = 'File'; File = [PSCustomObject]@{}; EventLog = [PSCustomObject]@{} } }
                $script:capturedEntry = $null
                Mock Write-SecretRotationFileLogEntry { $script:capturedEntry = $Entry }

                Write-SecretRotationLog -Config ([PSCustomObject]@{}) -Level Debug -Message 'Cmdlet invoked' -CmdletName 'Test' `
                    -BoundParameters @{ Password = 'super-secret-value'; Identifier = 'svc1' }

                $script:capturedEntry.Message | Should -Not -BeLike '*super-secret-value*'
                $script:capturedEntry.Message | Should -BeLike '*Password=<redacted>*'
                $script:capturedEntry.Message | Should -BeLike '*Identifier=svc1*'
            }

            It 'stamps UserName from [System.Environment]::UserName' {
                Mock Get-SecretRotationLoggingConfig { [PSCustomObject]@{ Enabled = $true; MinimumLevel = 'Debug'; Mode = 'File'; File = [PSCustomObject]@{}; EventLog = [PSCustomObject]@{} } }
                $script:capturedEntry = $null
                Mock Write-SecretRotationFileLogEntry { $script:capturedEntry = $Entry }

                Write-SecretRotationLog -Config ([PSCustomObject]@{}) -Level Information -Message 'msg' -CmdletName 'Test'

                $script:capturedEntry.UserName | Should -Be ([System.Environment]::UserName)
            }

            It 'never throws even when the logging config itself is broken' {
                Mock Get-SecretRotationLoggingConfig { throw 'config is corrupt' }
                Mock Write-Warning {}

                { Write-SecretRotationLog -Config ([PSCustomObject]@{}) -Level Information -Message 'msg' -CmdletName 'Test' } | Should -Not -Throw
                Should -Invoke Write-Warning -Times 1 -Exactly
            }
        }
    }
}