#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# Each record type matches contracts/audit-record.md. The contract is what downstream ingestion will be written against, so a field quietly renamed here breaks a consumer that this repository never sees. These tests are the contract's enforcement. #> BeforeAll { $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent . (Join-Path $repoRoot 'tests/TestHelpers.ps1') foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } $script:envelope = @('timestamp', 'recordType', 'runId', 'engineVersion', 'configVersion', 'configurationHash', 'mode') $script:counterFields = @('processed', 'matched', 'unclassified', 'evaluationError', 'unchanged', 'wouldUpdate', 'updated', 'updateFailed', 'skipped', 'reconciliationPassed') function New-Result { param( [string] $Outcome = 'Matched', [string] $Action = 'Unchanged', [string] $MatchedRuleId = 'RULE-0010-GUEST', [string] $ErrorReason = $null ) [pscustomobject]@{ AccountObjectId = '00000000-0000-0000-0000-000000000101' UserPrincipalName = 'alex@example.invalid' Outcome = $Outcome MatchedRuleId = $MatchedRuleId CalculatedPersona = 'Guest' StoredPersona = 'Employee' Action = $Action EvaluationErrorReason = $ErrorReason RulesEvaluated = 3 DurationMs = 12 ConditionTrace = $null } } } Describe 'Common envelope' { It 'appears on every record type' { $counters = New-PersonaRunCounter -Rules @() $context = New-TestAuditContext $records = @( New-PersonaAuditRecord -Context $context -RecordType 'RunStart' New-PersonaAuditRecord -Context $context -RecordType 'UserEvent' -Result (New-Result) New-PersonaAuditRecord -Context $context -RecordType 'Summary' -Counters $counters New-PersonaAuditRecord -Context $context -RecordType 'EngineDefect' -Properties @{ severity = 'Error' } Export-PersonaRunReport -Context $context -Counters $counters -StartedUtc ([DateTime]::UtcNow) -ExitCode 0 ) foreach ($record in $records) { foreach ($field in $script:envelope) { $record.Contains($field) | Should -BeTrue -Because "$($record['recordType']) must carry '$field'" } } } It 'places the envelope first, so a truncated line still identifies the run' { $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result (New-Result) @($record.Keys)[0..6] | Should -Be $script:envelope } } Describe 'UserEvent shape' { It 'carries every documented field' { $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result (New-Result) foreach ($field in @('accountObjectId', 'userPrincipalName', 'outcome', 'matchedRuleId', 'storedPersona', 'calculatedPersona', 'previousValue', 'action', 'rulesEvaluated', 'durationMs', 'evaluationErrorReason')) { $record.Contains($field) | Should -BeTrue -Because "the contract names '$field'" } } It 'populates previousValue only on an Updated action' { $updated = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' ` -Result (New-Result -Action 'Updated') -PreviousValue 'Employee' $wouldUpdate = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' ` -Result (New-Result -Action 'WouldUpdate') -PreviousValue 'Employee' $updated['previousValue'] | Should -Be 'Employee' $wouldUpdate['previousValue'] | Should -BeNullOrEmpty } It 'carries a null matchedRuleId on an Unclassified record' { $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' ` -Result (New-Result -Outcome 'Unclassified' -MatchedRuleId $null) $record['outcome'] | Should -Be 'Unclassified' $record['matchedRuleId'] | Should -BeNullOrEmpty } It 'carries an evaluationErrorReason on an EvaluationError record' { $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' ` -Result (New-Result -Outcome 'EvaluationError' -MatchedRuleId $null -Action 'Skipped' -ErrorReason 'membership lookup failed') $record['evaluationErrorReason'] | Should -Be 'membership lookup failed' } It 'omits conditionTrace unless tracing is explicitly requested' { $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result (New-Result) $record.Contains('conditionTrace') | Should -BeFalse } It 'refuses to build a UserEvent without a result' { { New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' } | Should -Throw } } Describe 'Summary shape' { It 'carries the summary type, counters, and rule counts' { $rules = @( [pscustomobject]@{ id = 'RULE-0010'; name = 'Guests'; enabled = $true; priority = 10; persona = 'Guest'; match = $null } [pscustomobject]@{ id = 'RULE-0020'; name = 'Disabled'; enabled = $false; priority = 20; persona = 'Employee'; match = $null } ) $counters = New-PersonaRunCounter -Rules $rules $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'Summary' ` -Counters $counters -Properties @{ summaryType = 'Interim' } $record['summaryType'] | Should -Be 'Interim' foreach ($field in $script:counterFields) { $record.Contains($field) | Should -BeTrue } $record['ruleCounts'].Count | Should -Be 2 $record['ruleCounts'][0]['ruleId'] | Should -Be 'RULE-0010' $record['ruleCounts'][1]['enabled'] | Should -BeFalse } It 'defaults to Interim when no summary type is given' { $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'Summary' ` -Counters (New-PersonaRunCounter -Rules @()) $record['summaryType'] | Should -Be 'Interim' } It 'refuses to build a Summary without counters' { { New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'Summary' } | Should -Throw } } Describe 'RunComplete shape' { It 'carries the timing fields, counters, and exit code' { $record = Export-PersonaRunReport -Context (New-TestAuditContext) ` -Counters (New-PersonaRunCounter -Rules @()) -StartedUtc ([DateTime]::UtcNow.AddSeconds(-3)) -ExitCode 4 foreach ($field in (@('startedUtc', 'completedUtc', 'durationMs', 'exitCode') + $script:counterFields)) { $record.Contains($field) | Should -BeTrue } $record['exitCode'] | Should -Be 4 } } Describe 'NDJSON serialization' { It 'writes one record per line to file' { $path = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-ndjson-{0}.log" -f [guid]::NewGuid().ToString('N')) $context = New-TestAuditContext try { 1..3 | ForEach-Object { New-PersonaAuditRecord -Context $context -RecordType 'UserEvent' -Result (New-Result) | Write-PersonaAuditRecord -Destination 'file' -Path $path } $lines = @(Get-Content -LiteralPath $path) $lines.Count | Should -Be 3 foreach ($line in $lines) { $line | Should -Not -Match "`n" { $line | ConvertFrom-Json } | Should -Not -Throw ($line | ConvertFrom-Json).recordType | Should -Be 'UserEvent' } } finally { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } } It 'emits nothing when the destination is none' { $path = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-none-{0}.log" -f [guid]::NewGuid().ToString('N')) New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'RunStart' | Write-PersonaAuditRecord -Destination 'none' -Path $path Test-Path -LiteralPath $path | Should -BeFalse } It 'warns once and keeps running when the file sink fails' { # A locked log file is an operational problem with the sink, not a reason to # abandon a run mid-population and leave the directory half-reconciled. $state = New-PersonaAuditSinkState $badPath = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-bad-{0}" -f [guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Path $badPath -Force try { $warnings = @() 1..5 | ForEach-Object { New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'RunStart' | Write-PersonaAuditRecord -Destination 'file' -Path $badPath -State $state 3>&1 | ForEach-Object { $warnings += $_ } } $state.FileSinkFailed | Should -BeTrue @($warnings).Count | Should -Be 1 } finally { Remove-Item -LiteralPath $badPath -Recurse -Force -ErrorAction SilentlyContinue } } }