tests/Locale.Regression.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# .SYNOPSIS Locale-regression tests for the Private/ locale-invariant helpers. .DESCRIPTION Cross-platform tests (run green on macOS and on the Windows CI lint job): * Fixture equivalence (WinLogKit PR #58 pattern): the English and German (ANSI/cp1252, localized header + names + inclusion text) auditpol /backup fixtures in tests/fixtures/ must parse to IDENTICAL GUID -> Value maps via ConvertFrom-AuditPolicyBackupCsv. Junk Option: rows and blank lines are filtered by the GUID regex; System rows win over per-user rows. * Static regression guard ('no text readers'): locale-fragile parsing patterns are forbidden in every module file. All legacy call sites were migrated to the Private/ helpers in v1.6.0, so the forbidden lists below carry NO grandfather entries — any occurrence anywhere fails the build. * Helper contract/degradation on non-Windows hosts: Get-AuditPolicyTable, Get-LocalPasswordPolicy, Get-WlanProfileList and Get-LocalUserByRid degrade to Unavailable/empty/$null instead of throwing; Test-IdentityReferenceIsSid and Get-WellKnownSid honor their SID contracts on every platform. One documented seam does NOT exist yet and its test is skipped on purpose: the auditpol /get CSV parse is still inline in Get-AuditPolicyTable (strategy 3, honest-unknown on localized inclusion text). The NetApi seconds->days / TIMEQ_FOREVER (0xFFFFFFFF) -> -1 conversion seam now EXISTS as the pure ConvertTo-PolicyFromModals helper in Private/Get-LocalPasswordPolicy.ps1 and is covered by the tests below. The module targets Windows PowerShell 5.1+, so only APIs available in 5.1 are used here (no null-coalescing/ternary operators, no ForEach-Object -Parallel). #> BeforeDiscovery { $script:ModuleRoot = Split-Path -Parent $PSScriptRoot $script:ModuleName = 'WindowsSecurityAudit' # Locale-fragile patterns that must never appear. All legacy call sites were # migrated to the Private/ helpers in v1.6.0 — any match anywhere fails the guard. # Every regex carries BadSamples: representative offending snippets the regex # MUST match, pinned by the guard self-test below so a regex can never # silently rot into a dead guard. $script:ForbiddenReaders = @( @{ Name = "raw 'auditpol /get' text parsing (localized Inclusion columns)" Regex = 'auditpol(\.exe)?\s+/get\b' Grandfathered = @() BadSamples = @( '$output = auditpol /get /category:* /r' '$output = auditpol.exe /get /subcategory:"Logon"' ) } @{ Name = "'Minimum password length' label parsing of 'net accounts' output" Regex = 'Select-String\b(\s+-Pattern)?\s+["''][^"'']*[Pp]assword\s+[Ll]ength' Grandfathered = @() BadSamples = @( 'Select-String ''Minimum password length''' 'Select-String -Pattern ''Minimum password length''' ) } @{ Name = 'name-based Get-LocalGroupMember -Group with a string literal' Regex = 'Get-LocalGroupMember\b[^\n|]*-Group\s+["'']' Grandfathered = @() BadSamples = @('Get-LocalGroupMember -Group ''Administrators''') } @{ Name = 'name-based Get-LocalUser -Name Guest/Administrator' Regex = 'Get-LocalUser\b[^\n|]*-Name\s+["''](Guest|Administrator)["'']' Grandfathered = @() BadSamples = @('Get-LocalUser -Name ''Guest''') } @{ Name = 'string identities (Everyone / NT AUTHORITY\SYSTEM / BUILTIN\Administrators) in FileSystemAccessRule' Regex = 'FileSystemAccessRule\(\s*["''](Everyone|NT AUTHORITY\\SYSTEM|BUILTIN\\Administrators)["'']' Grandfathered = @() BadSamples = @('New-Object System.Security.AccessControl.FileSystemAccessRule(''Everyone'', ''FullControl'', ''Allow'')') } @{ Name = "IdentityReference matched against '(Everyone' style name patterns" Regex = 'IdentityReference\b[^\n]*-match\s+["''][^"'']*\(?Everyone' Grandfathered = @() BadSamples = @('$rule.IdentityReference -match ''^(Everyone|BUILTIN)''') } ) } BeforeAll { $script:ModuleRoot = Split-Path -Parent $PSScriptRoot $script:ModuleName = 'WindowsSecurityAudit' $script:ModulePath = Join-Path -Path $script:ModuleRoot -ChildPath "$script:ModuleName.psm1" $script:FixturePath = Join-Path -Path $PSScriptRoot -ChildPath 'fixtures' $script:EnFixture = Join-Path -Path $script:FixturePath -ChildPath 'auditpol-backup-en.csv' $script:DeFixture = Join-Path -Path $script:FixturePath -ChildPath 'auditpol-backup-de.csv' Import-Module -Name $script:ModulePath -Force -DisableNameChecking # Invocation-context guard for 'net accounts' reads (leading = | ; { ( & or # line start). Hardening/Set-SecurityBaseline.ps1 is allowed (legit /set writes). # Defined in BeforeAll (not BeforeDiscovery): run-phase script state is fresh, # so runtime references from It blocks need run-phase initialization. $script:NetAccountsGuard = @{ Name = "'net accounts' invocation outside Hardening/Set-SecurityBaseline.ps1" Regex = '(?m)(=|\||;|\{|\(|&)\s*net(\.exe)?\s+accounts\b|^[ \t]*net(\.exe)?\s+accounts\b' Allowed = @('Hardening/Set-SecurityBaseline.ps1') BadSamples = @( '$x = net accounts' '$x = & net accounts' ) } $script:IsWindowsHost = $false try { $script:IsWindowsHost = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( [System.Runtime.InteropServices.OSPlatform]::Windows) } catch { # RuntimeInformation missing on old .NET Framework (PS 5.1) $script:IsWindowsHost = ($env:OS -eq 'Windows_NT') } # Every module .ps1 file except the tests folder and Private/Get-AuditPolicyTable.ps1 # (the one file allowed to touch auditpol /get output, covered by fixtures instead) $script:GuardedFiles = @( Get-ChildItem -Path $script:ModuleRoot -Filter '*.ps1' -Recurse -File | Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' -and $_.FullName -notmatch '[\\/]tests[\\/]' -and $_.FullName -ne (Join-Path -Path $script:ModuleRoot -ChildPath 'Private/Get-AuditPolicyTable.ps1') } | ForEach-Object { $relative = $_.FullName.Substring($script:ModuleRoot.Length) -replace '^[\\/]+', '' -replace '[\\/]', '/' [PSCustomObject]@{ FullName = $_.FullName; RelativePath = $relative } } ) } AfterAll { Remove-Module -Name $script:ModuleName -Force -ErrorAction SilentlyContinue } Describe 'ConvertFrom-AuditPolicyBackupCsv fixture equivalence (WinLogKit PR #58 pattern)' { It 'parses the German ANSI/cp1252 fixture without throwing (salt#68354)' { InModuleScope -ModuleName $script:ModuleName -ArgumentList $script:DeFixture -ScriptBlock { param($deFixture) { ConvertFrom-AuditPolicyBackupCsv -Path $deFixture } | Should -Not -Throw } } It 'pins the English fixture to the expected GUID -> Value map' { InModuleScope -ModuleName $script:ModuleName -ArgumentList $script:EnFixture -ScriptBlock { param($enFixture) $expected = @{ '0cce9210-69ae-11d9-bed3-505054503030' = 3 # Security State Change '0cce9212-69ae-11d9-bed3-505054503030' = 3 # System Integrity '0cce9215-69ae-11d9-bed3-505054503030' = 3 # Logon '0cce9216-69ae-11d9-bed3-505054503030' = 1 # Logoff '0cce921b-69ae-11d9-bed3-505054503030' = 1 # Special Logon '0cce921d-69ae-11d9-bed3-505054503030' = 0 # File System (No Auditing) '0cce9228-69ae-11d9-bed3-505054503030' = 2 # Sensitive Privilege Use (Failure) '0cce922b-69ae-11d9-bed3-505054503030' = 1 # Process Creation '0cce923b-69ae-11d9-bed3-505054503030' = 2 # Directory Service Access '0cce923f-69ae-11d9-bed3-505054503030' = 3 # Credential Validation } $map = ConvertFrom-AuditPolicyBackupCsv -Path $enFixture $map.Count | Should -Be $expected.Count foreach ($key in @($expected.Keys)) { $map.ContainsKey($key) | Should -BeTrue -Because "GUID $key must be parsed" $map[$key] | Should -Be $expected[$key] -Because "numeric Setting Value is locale-invariant ($key)" } } } It 'produces IDENTICAL GUID -> Value maps from the English and the localized German fixture' { InModuleScope -ModuleName $script:ModuleName -ArgumentList $script:EnFixture, $script:DeFixture -ScriptBlock { param($enFixture, $deFixture) $enMap = ConvertFrom-AuditPolicyBackupCsv -Path $enFixture $deMap = ConvertFrom-AuditPolicyBackupCsv -Path $deFixture $deMap.Count | Should -Be $enMap.Count $enKeys = @($enMap.Keys | Sort-Object) $deKeys = @($deMap.Keys | Sort-Object) ($deKeys -join ',') | Should -Be ($enKeys -join ',') -Because 'subcategory GUIDs are locale-invariant' foreach ($key in $enKeys) { $deMap[$key] | Should -Be $enMap[$key] -Because "localized names/inclusion text must not change the numeric value ($key)" } } } It 'filters the Option:CrashOnAuditFail junk row and blank lines via the GUID regex' { InModuleScope -ModuleName $script:ModuleName -ArgumentList $script:EnFixture -ScriptBlock { param($enFixture) # The fixture carries 10 real subcategory rows plus one Option: row # (no GUID), one blank line and one per-user row: exactly 10 survive $map = ConvertFrom-AuditPolicyBackupCsv -Path $enFixture $map.Count | Should -Be 10 -Because 'the Option: junk row, the blank line and the per-user row must not add entries' @($map.Keys | Where-Object { $_ -notmatch '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$' }).Count | Should -Be 0 -Because 'every surviving key must be a bare GUID' } } It 'lets System rows win over per-user rows for the same GUID' { InModuleScope -ModuleName $script:ModuleName -ArgumentList $script:EnFixture, $script:DeFixture -ScriptBlock { param($enFixture, $deFixture) # Both fixtures carry a per-user Logon row (value 1) after the System row (value 3) $enMap = ConvertFrom-AuditPolicyBackupCsv -Path $enFixture $deMap = ConvertFrom-AuditPolicyBackupCsv -Path $deFixture $enMap['0cce9215-69ae-11d9-bed3-505054503030'] | Should -Be 3 -Because 'the System row must win over the per-user SID row' $deMap['0cce9215-69ae-11d9-bed3-505054503030'] | Should -Be 3 -Because 'the System row must win over the per-user SID row' } } It 'falls back to per-user rows only when no System rows exist' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $lines = @( 'Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting,Setting Value' 'WSA-TEST01,S-1-5-21-3623811015-3361044348-30300820-1001,Logon,{0CCE9215-69AE-11D9-BED3-505054503030},Success,,1' ) $map = ConvertFrom-AuditPolicyBackupCsv -Lines $lines $map['0cce9215-69ae-11d9-bed3-505054503030'] | Should -Be 1 -Because 'with no System row present the per-user row is the only source' } } It 'merges per-GUID: a per-user-only GUID survives next to a GUID where the System row wins' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $lines = @( 'Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting,Setting Value' 'WSA-TEST01,System,Logon,{0CCE9215-69AE-11D9-BED3-505054503030},Success and Failure,,3' 'WSA-TEST01,S-1-5-21-3623811015-3361044348-30300820-1001,Logoff,{0CCE9216-69AE-11D9-BED3-505054503030},Success,,1' ) $map = ConvertFrom-AuditPolicyBackupCsv -Lines $lines $map.Count | Should -Be 2 -Because 'a GUID with only per-user rows must not be dropped just because another GUID has a System row' $map['0cce9215-69ae-11d9-bed3-505054503030'] | Should -Be 3 -Because 'the System row wins for Logon' $map['0cce9216-69ae-11d9-bed3-505054503030'] | Should -Be 1 -Because 'Logoff has only the per-user row, so it is the source' } } It 'lets the System row win even when the per-user row comes first (reordered rows)' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $lines = @( 'Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting,Setting Value' 'WSA-TEST01,S-1-5-21-3623811015-3361044348-30300820-1001,Logon,{0CCE9215-69AE-11D9-BED3-505054503030},Success,,1' 'WSA-TEST01,System,Logon,{0CCE9215-69AE-11D9-BED3-505054503030},Success and Failure,,3' ) $map = ConvertFrom-AuditPolicyBackupCsv -Lines $lines $map.Count | Should -Be 1 $map['0cce9215-69ae-11d9-bed3-505054503030'] | Should -Be 3 -Because 'row order must not matter: System wins regardless of position' } } It 'drops rows whose Setting Value is outside the 0-3 range' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $lines = @( 'Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting,Setting Value' 'WSA-TEST01,System,Logon,{0CCE9215-69AE-11D9-BED3-505054503030},Success,,5' 'WSA-TEST01,System,Logoff,{0CCE9216-69AE-11D9-BED3-505054503030},Success,,1' ) $map = ConvertFrom-AuditPolicyBackupCsv -Lines $lines $map.Count | Should -Be 1 -Because "'5' is not a valid audit setting value (0-3) and the row must be rejected" $map.ContainsKey('0cce9215-69ae-11d9-bed3-505054503030') | Should -BeFalse $map['0cce9216-69ae-11d9-bed3-505054503030'] | Should -Be 1 } } It 'lets the first System row win when duplicate System rows exist for one GUID' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $lines = @( 'Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting,Setting Value' 'WSA-TEST01,System,Logon,{0CCE9215-69AE-11D9-BED3-505054503030},Success and Failure,,3' 'WSA-TEST01,System,Logon,{0CCE9215-69AE-11D9-BED3-505054503030},Success,,1' ) $map = ConvertFrom-AuditPolicyBackupCsv -Lines $lines $map.Count | Should -Be 1 $map['0cce9215-69ae-11d9-bed3-505054503030'] | Should -Be 3 -Because 'the first System row for a GUID wins' } } It 'accepts raw -Lines with the same result as -Path' { InModuleScope -ModuleName $script:ModuleName -ArgumentList $script:EnFixture -ScriptBlock { param($enFixture) $fromPath = ConvertFrom-AuditPolicyBackupCsv -Path $enFixture # No -Encoding, exactly like the production read of the backup file $fromLines = ConvertFrom-AuditPolicyBackupCsv -Lines @(Get-Content -LiteralPath $enFixture) $fromLines.Count | Should -Be $fromPath.Count foreach ($key in @($fromPath.Keys)) { $fromLines[$key] | Should -Be $fromPath[$key] } } } It 'returns an empty map for content without a header and data rows' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $map = ConvertFrom-AuditPolicyBackupCsv -Lines @('header only') $map.Count | Should -Be 0 } } # SKIPPED (no parse seam): the auditpol /get CSV parse (6 columns, no numeric # value, English-only Inclusion text) is still inline in Get-AuditPolicyTable # (strategy 3) and the change under test allowed extracting only the BackupCsv # strategy. Once a ConvertFrom-AuditPolicyGetCsv-style seam exists, add a German # /r fixture ('Erfolg und Fehler') asserting Value stays $null (honest unknown) # instead of being guessed from localized text. It 'never guesses values from localized auditpol /get inclusion text' -Skip { } } Describe 'No locale-fragile text readers (static regression guard)' { It 'no new file uses <Name>' -ForEach $script:ForbiddenReaders { $offenders = @( foreach ($file in $script:GuardedFiles) { if ($_.Grandfathered -contains $file.RelativePath) { continue } $content = Get-Content -Path $file.FullName -Raw if ($null -ne $content -and $content -match $_.Regex) { $file.RelativePath } } ) $offenders.Count | Should -Be 0 -Because ("locale-fragile pattern '{0}' is forbidden module-wide since v1.6.0 (use the Private/ locale-invariant helpers): {1}" -f $_.Name, ($offenders -join ', ')) } It "'net accounts' appears only in Hardening/Set-SecurityBaseline.ps1 (legit /set usage)" { # Invocation context only: the doc comment in Private/Get-LocalPasswordPolicy.ps1 # ('NEVER calls net accounts') is documentation, not an invocation $regex = $script:NetAccountsGuard.Regex $allowed = $script:NetAccountsGuard.Allowed $offenders = @( foreach ($file in $script:GuardedFiles) { if ($allowed -contains $file.RelativePath) { continue } $content = Get-Content -Path $file.FullName -Raw if ($null -ne $content -and $content -match $regex) { $file.RelativePath } } ) $offenders.Count | Should -Be 0 -Because ("'net accounts' output is localized; reads must go through Get-LocalPasswordPolicy, writes belong to Set-SecurityBaseline: {0}" -f ($offenders -join ', ')) } It 'guard regex still matches its representative bad samples: <Name>' -ForEach $script:ForbiddenReaders { foreach ($sample in $_.BadSamples) { $sample -match $_.Regex | Should -BeTrue -Because "a guard regex that no longer matches its own bad sample is a dead guard (sample: $sample)" } } It "guard regex still matches its representative bad samples: net accounts invocation" { foreach ($sample in $script:NetAccountsGuard.BadSamples) { $sample -match $script:NetAccountsGuard.Regex | Should -BeTrue -Because "a guard regex that no longer matches its own bad sample is a dead guard (sample: $sample)" } } } Describe 'Private helper contract and degradation' { Context 'non-Windows hosts (macOS/Linux dev boxes and CI)' { It 'Get-AuditPolicyTable returns 59 entries, all Source=Unavailable with null Values' { if ($script:IsWindowsHost) { Set-ItResult -Skipped -Because 'on Windows the table resolves real data via Api/BackupCsv instead of degrading' return } InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $table = Get-AuditPolicyTable -Force $table.Count | Should -Be 59 -Because 'one entry per known audit subcategory GUID' $entries = @($table.Values) @($entries | Where-Object { $_.Source -ne 'Unavailable' }).Count | Should -Be 0 @($entries | Where-Object { $null -ne $_.Value }).Count | Should -Be 0 @($entries | Where-Object { $_.Success -or $_.Failure }).Count | Should -Be 0 } } It 'Get-LocalPasswordPolicy degrades to Source=Unavailable' { if ($script:IsWindowsHost) { Set-ItResult -Skipped -Because 'on Windows the SAM policy resolves via NetApi/Secedit instead of degrading' return } InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $policy = Get-LocalPasswordPolicy $policy.Source | Should -Be 'Unavailable' $policy.MinPasswordLength | Should -BeNullOrEmpty $policy.MaxPasswordAgeDays | Should -BeNullOrEmpty } } It 'Get-WlanProfileList returns an empty array' { if ($script:IsWindowsHost) { Set-ItResult -Skipped -Because 'on Windows the WLAN API may return real profiles' return } InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $profiles = @(Get-WlanProfileList) $profiles.Count | Should -Be 0 } } It 'Get-LocalUserByRid -Rid 500 returns null instead of throwing' { if ($script:IsWindowsHost) { Set-ItResult -Skipped -Because 'on Windows RID 500 (built-in Administrator) exists and resolves' return } InModuleScope -ModuleName $script:ModuleName -ScriptBlock { { Get-LocalUserByRid -Rid 500 } | Should -Not -Throw Get-LocalUserByRid -Rid 500 | Should -BeNullOrEmpty } } } Context 'platform-independent identity helpers' { It 'Test-IdentityReferenceIsSid matches a raw unresolved SID string against the same SID' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { Test-IdentityReferenceIsSid -IdentityReference 'S-1-1-0' -Sid 'S-1-1-0' | Should -BeTrue } } It 'Test-IdentityReferenceIsSid rejects a raw SID string against a different SID' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { Test-IdentityReferenceIsSid -IdentityReference 'S-1-1-0' -Sid 'S-1-5-32-544' | Should -BeFalse } } It 'Test-IdentityReferenceIsSid returns false for null input' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { Test-IdentityReferenceIsSid -IdentityReference $null -Sid 'S-1-1-0' | Should -BeFalse } } It 'Get-WellKnownSid contains the documented mappings' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $sids = Get-WellKnownSid $sids.Administrators | Should -Be 'S-1-5-32-544' $sids.Users | Should -Be 'S-1-5-32-545' $sids.Guests | Should -Be 'S-1-5-32-546' $sids.BackupOperators | Should -Be 'S-1-5-32-551' $sids.RemoteDesktopUsers | Should -Be 'S-1-5-32-555' $sids.System | Should -Be 'S-1-5-18' $sids.LocalService | Should -Be 'S-1-5-19' $sids.NetworkService | Should -Be 'S-1-5-20' $sids.Everyone | Should -Be 'S-1-1-0' $sids.AuthenticatedUsers | Should -Be 'S-1-5-11' } } It 'Test-IdentityReferenceIsSid matches a SecurityIdentifier object input against its SID string' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { # On non-Windows .NET the whole System.Security.Principal.Windows # surface (including every SecurityIdentifier constructor) throws # PlatformNotSupportedException, so the object cannot be built there; # the assertion itself runs wherever the type is constructible. $sidObject = $null try { $sidObject = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-544' } catch { Set-ItResult -Skipped -Because 'SecurityIdentifier cannot be constructed on this platform (Windows Principal functionality is not supported)' return } Test-IdentityReferenceIsSid -IdentityReference $sidObject -Sid 'S-1-5-32-544' | Should -BeTrue Test-IdentityReferenceIsSid -IdentityReference $sidObject -Sid 'S-1-1-0' | Should -BeFalse } } } Context 'Get-AuditSubcategoryGuid name resolution' { It 'resolves subcategory names case-insensitively' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { Get-AuditSubcategoryGuid -Name 'logon' | Should -Be '{0CCE9215-69AE-11D9-BED3-505054503030}' Get-AuditSubcategoryGuid -Name 'Logon' | Should -Be '{0CCE9215-69AE-11D9-BED3-505054503030}' } } It 'returns null for an unknown subcategory name' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { Get-AuditSubcategoryGuid -Name 'Not A Real Subcategory' | Should -BeNullOrEmpty } } } Context 'Get-AuditPolicyTable subset selection and cache behavior' { It 'returns exactly the requested subcategories' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $table = Get-AuditPolicyTable -Subcategory 'Logon', 'Logoff' $table.Count | Should -Be 2 $table.ContainsKey('0cce9215-69ae-11d9-bed3-505054503030') | Should -BeTrue $table.ContainsKey('0cce9216-69ae-11d9-bed3-505054503030') | Should -BeTrue } } It 'skips unknown names in -Subcategory' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $table = Get-AuditPolicyTable -Subcategory 'Logon', 'Logoff', 'Not A Real Subcategory' $table.Count | Should -Be 2 -Because 'unknown subcategory names are skipped, not fatal' } } It 'serves equivalent content from the cache on repeated calls without -Force' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $first = Get-AuditPolicyTable $second = Get-AuditPolicyTable $second.Count | Should -Be $first.Count $firstKeys = @($first.Keys | Sort-Object) (@($second.Keys | Sort-Object) -join ',') | Should -Be ($firstKeys -join ',') foreach ($key in $firstKeys) { $second[$key].Value | Should -Be $first[$key].Value $second[$key].Success | Should -Be $first[$key].Success $second[$key].Failure | Should -Be $first[$key].Failure $second[$key].Source | Should -Be $first[$key].Source } } } It 'never lets callers corrupt the cache (clone-on-read)' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $table = Get-AuditPolicyTable $entry = $table['0cce9215-69ae-11d9-bed3-505054503030'] $originalValue = $entry.Value $entry.Value = 999 $fresh = Get-AuditPolicyTable $fresh['0cce9215-69ae-11d9-bed3-505054503030'].Value | Should -Be $originalValue -Because 'mutating a returned table must not corrupt the cached table' # Hygiene: rebuild the cache so no later test inherits state from this one $null = Get-AuditPolicyTable -Force } } } Context 'Get-LocalUserByRid RID suffix matching' { It 'does not match a user whose SID merely ends with the RID digits (RID 500 vs ...-1500)' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { # Get-LocalUser does not exist on non-Windows hosts and Pester can # only mock existing commands, so define a stub first. The SID is a # duck-typed .Value holder because SecurityIdentifier constructors # throw PlatformNotSupportedException off Windows; the code under # test only ever touches $_.SID.Value. function Get-LocalUser { } Mock Get-LocalUser { @( [PSCustomObject]@{ Name = 'Rid1500User' SID = [PSCustomObject]@{ Value = 'S-1-5-21-3623811015-3361044348-30300820-1500' } Enabled = $true Description = '' } ) } # On Windows the CIM fallback would find the REAL RID-500 account # (e.g. the CI runner's administrator) when the mocked Get-LocalUser # returns no match — neutralize the fallback inside this test. if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { Mock Get-CimInstance { @() } } Get-LocalUserByRid -Rid 500 | Should -BeNullOrEmpty -Because 'SID ...-1500 must not match RID 500 (suffix match includes the dash)' $hit = Get-LocalUserByRid -Rid 1500 $hit.Name | Should -Be 'Rid1500User' -Because 'the mock wiring must actually be exercised (positive control)' } } } Context 'ConvertTo-PolicyFromModals pure conversion seam' { It 'converts NetApi seconds to days/hours/minutes and TIMEQ_FOREVER (0xFFFFFFFF) to -1' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $modals0 = @{ usrmod0_min_passwd_len = [uint32]7 usrmod0_max_passwd_age = [uint32]3628800 # 42 days, in seconds usrmod0_min_passwd_age = [uint32]86400 # 1 day, in seconds usrmod0_force_logoff = [uint32]::MaxValue # TIMEQ_FOREVER usrmod0_password_hist_len = [uint32]5 } $modals3 = @{ usrmod3_lockout_duration = [uint32]1800 # 30 minutes, in seconds usrmod3_lockout_observation_window = [uint32]600 # 10 minutes, in seconds usrmod3_lockout_threshold = [uint32]5 } $policy = ConvertTo-PolicyFromModals -Modals0 $modals0 -Modals3 $modals3 $policy.MinPasswordLength | Should -Be 7 $policy.MaxPasswordAgeDays | Should -Be 42 $policy.MinPasswordAgeDays | Should -Be 1 $policy.PasswordHistoryLength | Should -Be 5 $policy.ForceLogoffHours | Should -Be -1 -Because 'TIMEQ_FOREVER (0xFFFFFFFF) maps to -1' $policy.LockoutThreshold | Should -Be 5 $policy.LockoutDurationMinutes | Should -Be 30 $policy.LockoutObservationWindowMinutes | Should -Be 10 } } It 'maps a TIMEQ_FOREVER lockout duration to -1 and null Modals0 to null fields' { InModuleScope -ModuleName $script:ModuleName -ScriptBlock { $modals3 = @{ usrmod3_lockout_duration = [uint32]::MaxValue # TIMEQ_FOREVER: until admin unlocks usrmod3_lockout_observation_window = [uint32]600 usrmod3_lockout_threshold = [uint32]0 } $policy = ConvertTo-PolicyFromModals -Modals0 $null -Modals3 $modals3 $policy.LockoutDurationMinutes | Should -Be -1 $policy.LockoutObservationWindowMinutes | Should -Be 10 $policy.LockoutThreshold | Should -Be 0 $policy.MinPasswordLength | Should -BeNullOrEmpty $policy.MaxPasswordAgeDays | Should -BeNullOrEmpty $policy.MinPasswordAgeDays | Should -BeNullOrEmpty $policy.PasswordHistoryLength | Should -BeNullOrEmpty $policy.ForceLogoffHours | Should -BeNullOrEmpty } } } } |