Implement Stage A: rule engine, validation, audit, and safety gates

Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).

  354 offline Pester tests      PASS
  Engine purity (Principle IV)  PASS
  Sanitization (SC-013)         PASS  (156 files)
  Graph module loaded in tests  none  (SC-008 holds)

What landed
  - Four-layer configuration validation with stable finding codes, covering
    every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
  - Run loop, audit records (NDJSON through a single sink), summaries,
    reconciliation, and exit codes 0-6
  - Persistence behind a single write-body builder whose result always has
    exactly one key
  - Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
  - Six docs, two pipelines, traceability matrix, V-5a and sanitization records

Three deviations from tasks.md, each recorded in its status block

  T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
  state and the rule engine is pure; a counter there would break Principle IV.
  It lives in New-PersonaRunCounter and is applied in the run loop.

  A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
  imports the manifest, which requires Microsoft.Graph.Authentication, so a
  loop living only inside it could not run on a machine without the Graph SDK
  and SC-004 could not be proven at all. The entry script is now a thin
  wrapper and what ships is what is tested.

  The invalid-config corpus is generated by a committed script, with the
  generated fixtures committed too, so a reviewer sees the fixture in the diff.

Defects found by running the code, not by reading it

  Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
  around a comma-returned array collapsed every membership list into one
  bogus space-joined entry. That is a silent false non-match, exactly what
  FR-013 exists to prevent.

  A 403 whose status appears only in the exception message parsed as $null,
  which the retry policy treats as a transport error - five requests per
  account against a tenant already refusing. Status extraction now falls back
  to the message text, bounded to 400-599.

  The sanitization scan walked tracked files only, so it covered 34 of 156
  files and none of this phase's code. It now scans untracked non-ignored
  files too, and a negative control confirms it catches a planted leak.

  Test-Json reports one error per violating location, not first-failure-only
  as the V-5a draft claimed. Record and pin corrected.

Enforcement remains blocked on the V-4 security sign-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 21:48:19 -04:00
parent c59c85dd55
commit cdc6bb33d3
124 changed files with 16638 additions and 199 deletions
+170
View File
@@ -0,0 +1,170 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-006, NFR-005: 100% of user events carry run ID, UPN, and Account Object ID, and
100% of matched results carry a rule ID.
"100%" is asserted across a whole run rather than on a single hand-built record.
A record builder can be correct in isolation and still be called wrongly on one
branch, and the branch that gets missed is invariably the error path - which is
the one an auditor will ask about.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:runId = '00000000-0000-0000-0000-00000000f001'
$script:rules = @(
(New-TestMembershipRule)
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
)
$script:config = New-TestRuntimeConfiguration -TargetAttribute $target -Rules $script:rules
}
Describe 'Audit completeness across a full run (SC-006)' {
BeforeAll {
Mock Get-PersonaUsers { @(New-TestPopulation -Count 24 -TargetAttribute $script:target) }
Mock Write-Host { }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
# A mixed run: some lookups fail so EvaluationError records are produced too.
$script:lookup = 0
Mock Get-PersonaGroupMembership {
$script:lookup++
($script:lookup % 4) -eq 0 `
? (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts') `
: (New-PersonaMembershipRecord -AllRetrieved)
}
$info = $null
$script:outcome = Invoke-PersonaEngineRun -Configuration $script:config -TargetAttribute $script:target `
-Context (New-TestAuditContext -Mode 'Enforce' -RunId $script:runId) `
-AuditParameters @{ Destination = 'stream' } `
-IsEnforcing -ShouldProcessGate { param($t, $d) $true } `
-InformationVariable info
$script:records = Get-CapturedAuditRecord -Captured $info
$script:userEvents = Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent'
}
It 'emits one UserEvent per processed user' {
$script:userEvents.Count | Should -Be $script:outcome.Counters.Processed
}
It 'carries the run ID on every record, not only user events' {
foreach ($record in $script:records) {
$record['runId'] | Should -Be $script:runId
}
}
It 'carries the UPN and Account Object ID on every user event' {
foreach ($event in $script:userEvents) {
$event['userPrincipalName'] | Should -Not -BeNullOrEmpty
$event['accountObjectId'] | Should -Not -BeNullOrEmpty
}
}
It 'carries a matched rule ID on every Matched record' {
$matched = @($script:userEvents | Where-Object { $_['outcome'] -eq 'Matched' })
$matched.Count | Should -BeGreaterThan 0
foreach ($event in $matched) {
$event['matchedRuleId'] | Should -Not -BeNullOrEmpty
}
}
It 'carries an evaluationErrorReason on exactly the EvaluationError records' {
$errors = @($script:userEvents | Where-Object { $_['outcome'] -eq 'EvaluationError' })
$errors.Count | Should -BeGreaterThan 0
foreach ($event in $script:userEvents) {
if ($event['outcome'] -eq 'EvaluationError') {
$event['evaluationErrorReason'] | Should -Not -BeNullOrEmpty
}
else {
$event['evaluationErrorReason'] | Should -BeNullOrEmpty
}
}
}
It 'carries the configuration version and hash on every record' {
foreach ($record in $script:records) {
$record['configVersion'] | Should -Be '1.0.0'
$record['configurationHash'] | Should -Not -BeNullOrEmpty
}
}
It 'carries the mode on every record' {
foreach ($record in $script:records) {
$record['mode'] | Should -Be 'Enforce'
}
}
It 'carries an ISO 8601 UTC timestamp on every record' {
foreach ($record in $script:records) {
$record['timestamp'] | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$'
}
}
It 'records previousValue on every Updated event and on no other' {
# Captured at write time. Without it, OTD-010 rollback is impossible
# retroactively - no later run can reconstruct what a value used to be.
$updated = @($script:userEvents | Where-Object { $_['action'] -eq 'Updated' })
$updated.Count | Should -BeGreaterThan 0
foreach ($event in $script:userEvents) {
if ($event['action'] -eq 'Updated') {
$event.Contains('previousValue') | Should -BeTrue
}
else {
$event['previousValue'] | Should -BeNullOrEmpty
}
}
}
It 'emits exactly one outcome value per user event' {
foreach ($event in $script:userEvents) {
$event['outcome'] | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError')
}
}
}
Describe 'Run report completeness' {
It 'produces a RunComplete record carrying the counters and the exit code' {
$counters = New-PersonaRunCounter -Rules $script:rules
$counters.Processed = 10
$counters.Matched = 8
$counters.Unclassified = 2
$record = Export-PersonaRunReport -Context (New-TestAuditContext) -Counters $counters `
-StartedUtc ([DateTime]::UtcNow.AddMinutes(-5)) -ExitCode 0
$record['recordType'] | Should -Be 'RunComplete'
$record['processed'] | Should -Be 10
$record['exitCode'] | Should -Be 0
$record['reconciliationPassed'] | Should -BeTrue
$record['startedUtc'] | Should -Match '^\d{4}-\d{2}-\d{2}T'
$record['completedUtc'] | Should -Match '^\d{4}-\d{2}-\d{2}T'
$record['durationMs'] | Should -BeGreaterThan 0
}
It 'refuses an exit code outside the documented range' {
$counters = New-PersonaRunCounter -Rules @()
{ Export-PersonaRunReport -Context (New-TestAuditContext) -Counters $counters `
-StartedUtc ([DateTime]::UtcNow) -ExitCode 9 } | Should -Throw
}
}
+221
View File
@@ -0,0 +1,221 @@
#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 }
}
}
+145
View File
@@ -0,0 +1,145 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
Principle V: no token, Authorization header, secret, or raw Graph response can
appear in an audit record.
The strongest guarantee here is structural rather than filtered. New-PersonaAuditRecord
accepts only named, typed values from the decision result and the counters - there
is no pass-through of an arbitrary object, so there is nothing for a secret to ride
in on. These tests assert that property holds, and that it still holds when a
caller actively tries to smuggle one in.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
# Values that must never survive into a record, each distinctive enough to find in
# a serialized blob.
$script:secrets = @(
'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.SYNTHETIC.TOKEN'
'Bearer SYNTHETIC-ACCESS-TOKEN-VALUE'
'SYNTHETIC-CLIENT-SECRET-VALUE'
)
$script:forbiddenKeys = @('authorization', 'accesstoken', 'access_token', 'token',
'clientsecret', 'client_secret', 'secret', 'password', 'rawresponse', 'credential')
}
Describe 'Audit records contain no credential material' {
BeforeAll {
Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) }
Mock Write-Host { }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
$info = $null
$null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
-AuditParameters @{ Destination = 'stream' } `
-IsEnforcing -ShouldProcessGate { param($t, $d) $true } `
-InformationVariable info
$script:records = Get-CapturedAuditRecord -Captured $info
}
It 'produced records to inspect' {
$script:records.Count | Should -BeGreaterThan 0
}
It 'contains no field whose name suggests credential material' {
foreach ($record in $script:records) {
foreach ($key in $record.Keys) {
$key.ToLowerInvariant() | Should -Not -BeIn $script:forbiddenKeys
}
}
}
It 'contains nothing that looks like a JWT once serialized' {
foreach ($record in $script:records) {
($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match 'eyJ[A-Za-z0-9_-]{10,}'
}
}
It 'contains no Bearer prefix once serialized' {
foreach ($record in $script:records) {
($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match '(?i)bearer\s'
}
}
}
Describe 'A record cannot be made to carry a secret through the decision result' {
It 'ignores unexpected fields on the decision result' {
# A result object carrying an extra field - as it would if some future adapter
# attached a raw response - must not propagate it. The builder reads named
# fields only.
$result = [pscustomobject]@{
AccountObjectId = '00000000-0000-0000-0000-000000000101'
UserPrincipalName = 'a@example.invalid'
Outcome = 'Matched'
MatchedRuleId = 'RULE-0010'
CalculatedPersona = 'Employee'
StoredPersona = 'Employee'
Action = 'Unchanged'
EvaluationErrorReason = $null
RulesEvaluated = 1
DurationMs = 2
ConditionTrace = $null
AccessToken = $script:secrets[1]
RawGraphResponse = @{ Authorization = $script:secrets[0] }
}
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result
$record.Contains('AccessToken') | Should -BeFalse
$record.Contains('RawGraphResponse') | Should -BeFalse
($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match 'SYNTHETIC'
}
It 'keeps the UPN and Object ID, which are approved for logs' {
# Redaction must not go so far that the record stops being useful. NFR-005
# requires both fields on every user event.
$result = [pscustomobject]@{
AccountObjectId = '00000000-0000-0000-0000-000000000101'
UserPrincipalName = 'a@example.invalid'
Outcome = 'Matched'; MatchedRuleId = 'RULE-0010'
CalculatedPersona = 'Employee'; StoredPersona = 'Employee'
Action = 'Unchanged'; EvaluationErrorReason = $null
RulesEvaluated = 1; DurationMs = 2; ConditionTrace = $null
}
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result
$record['userPrincipalName'] | Should -Be 'a@example.invalid'
$record['accountObjectId'] | Should -Be '00000000-0000-0000-0000-000000000101'
}
}
Describe 'The Graph request helper never logs credential material' {
It 'does not write the request body or headers to the verbose stream' {
Mock Invoke-MgGraphRequest { @{ value = @() } }
$captured = Invoke-PersonaGraphRequest -Uri '/v1.0/users' -Body @{ secret = $script:secrets[2] } -Method 'PATCH' -Verbose 4>&1 |
Out-String
$captured | Should -Not -Match 'Bearer'
$captured | Should -Not -Match 'eyJ'
$captured | Should -Not -Match 'SYNTHETIC'
}
It 'reports a failure without echoing the response body' {
Mock Invoke-MgGraphRequest { throw 'Response status code does not indicate success: 403 (Forbidden).' }
{ Invoke-PersonaGraphRequest -Uri '/v1.0/users' } | Should -Throw
# A 403 is never retried, so the message surfaces once, unchanged, and carries
# only what Graph put in the status line.
Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly
}
}
+141
View File
@@ -0,0 +1,141 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
US9: condition tracing is available only when explicitly enabled.
The default matters more than the feature. Tracing widens what an audit record
contains beyond the UPN and Object ID that are approved by default (Principle V),
so a trace that appeared without being asked for would quietly change what the log
holds - and nobody reviews a log for fields they did not know were there.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:rules = @(
[pscustomobject]@{
id = 'RULE-0010-GUEST'; name = 'Guests'; enabled = $true; priority = 10; persona = 'Guest'
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }) }
}
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) }
}
)
$script:record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'alex@example.invalid' -UserType 'Member' `
-Properties @{ Department = 'Finance' } -StoredPersona 'Employee' `
-Membership (New-PersonaMembershipRecord -AllRetrieved)
}
Describe 'Trace gating on the decision result' {
It 'leaves ConditionTrace null by default' {
$result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules
$result.ConditionTrace | Should -BeNullOrEmpty
}
It 'populates ConditionTrace when tracing is requested' {
$result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace
$result.ConditionTrace | Should -Not -BeNullOrEmpty
$result.ConditionTrace.Count | Should -Be $result.RulesEvaluated
}
It 'records the per-rule result and priority in the trace' {
$result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace
$result.ConditionTrace[0].RuleId | Should -Be 'RULE-0010-GUEST'
$result.ConditionTrace[0].Priority | Should -Be 10
$result.ConditionTrace[0].Result | Should -Be 'False'
$result.ConditionTrace[1].RuleId | Should -Be 'RULE-0900-EMPLOYEE'
$result.ConditionTrace[1].Result | Should -Be 'True'
}
It 'does not change the decision when tracing is on' {
# Tracing is diagnostic. If it could alter an outcome, a debug run would stop
# being evidence about the real one.
$plain = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules
$traced = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace
$traced.Outcome | Should -Be $plain.Outcome
$traced.MatchedRuleId | Should -Be $plain.MatchedRuleId
$traced.CalculatedPersona | Should -Be $plain.CalculatedPersona
$traced.RulesEvaluated | Should -Be $plain.RulesEvaluated
}
}
Describe 'Trace gating on the audit record' {
It 'omits conditionTrace when tracing is off' {
$result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace
$result.Action = 'Unchanged'
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result
# The result carries a trace, but the record was not asked to include it.
# Both gates have to be open.
$record.Contains('conditionTrace') | Should -BeFalse
}
It 'includes conditionTrace when both the result and the record are traced' {
$result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace
$result.Action = 'Unchanged'
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result -IncludeTrace
$record.Contains('conditionTrace') | Should -BeTrue
$record['conditionTrace'].Count | Should -Be 2
$record['conditionTrace'][0]['ruleId'] | Should -Be 'RULE-0010-GUEST'
}
It 'omits conditionTrace when tracing was requested but the result carries none' {
$result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules
$result.Action = 'Unchanged'
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result -IncludeTrace
$record.Contains('conditionTrace') | Should -BeFalse
}
}
Describe 'Trace gating across a run' {
BeforeEach {
Mock Write-Host { }
Mock Get-PersonaUsers { @(New-TestPopulation -Count 6 -TargetAttribute $script:target) }
}
It 'emits no condition values anywhere when tracing is off' {
$info = $null
$null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext) `
-AuditParameters @{ Destination = 'stream' } -InformationVariable info
foreach ($event in (Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent')) {
$event.Contains('conditionTrace') | Should -BeFalse
}
}
It 'emits a trace on every user event when tracing is on' {
$info = $null
$null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext) `
-AuditParameters @{ Destination = 'stream' } -Tracing -InformationVariable info
$events = Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent'
$events.Count | Should -BeGreaterThan 0
foreach ($event in $events) {
$event.Contains('conditionTrace') | Should -BeTrue
$event['conditionTrace'].Count | Should -BeGreaterThan 0
}
}
}
+191
View File
@@ -0,0 +1,191 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-011: every engine exit code 0-6 is reachable and returned for its documented
condition.
An exit code is what a scheduler, a pipeline, or an on-call runbook reacts to. A
code that cannot be produced is a promise the contract makes and the engine does
not keep, and it is invisible until the day someone builds an alert on it.
Codes 0, 3, 4, and 5 are reachable inside the run loop and are tested by fault
injection here. Codes 1, 2, and 6 are owned by the entry script, which decides
them before or around the loop; those are asserted against the script's own
control flow.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:entryScript = Join-Path $repoRoot 'Invoke-PersonaEngine.ps1'
}
Describe 'Exit codes produced by the run loop' {
BeforeEach {
Mock Write-Host { }
Mock Write-Verbose { }
}
It 'returns 0 for a clean run' {
Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) }
$outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext)
$outcome.ExitCode | Should -Be 0
}
It 'returns 3 when user enumeration fails' {
Mock Get-PersonaUsers { throw 'User enumeration returned an unexpected response shape on page 4.' }
$outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext)
$outcome.ExitCode | Should -Be 3
$outcome.FailureReason | Should -Not -BeNullOrEmpty
}
It 'processes nobody when enumeration fails, rather than reporting a partial run' {
Mock Get-PersonaUsers { throw 'truncated' }
$outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext)
$outcome.Counters.Processed | Should -Be 0
}
It 'returns 4 when the EvaluationError count exceeds the configured threshold' {
Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) }
Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts' }
$config = New-TestRuntimeConfiguration -TargetAttribute $script:target -EvaluationErrorThreshold 3 -Rules @(
(New-TestMembershipRule)
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) }
})
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $script:target -Context (New-TestAuditContext)
$outcome.Counters.EvaluationError | Should -BeGreaterThan 3
$outcome.ExitCode | Should -Be 4
}
It 'returns 0 when the EvaluationError count is at or below the threshold' {
Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) }
Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503' }
$config = New-TestRuntimeConfiguration -TargetAttribute $script:target -EvaluationErrorThreshold 100 -Rules @(
(New-TestMembershipRule)
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) }
})
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $script:target -Context (New-TestAuditContext)
$outcome.Counters.EvaluationError | Should -BeGreaterThan 0
$outcome.ExitCode | Should -Be 0
}
It 'leaves the threshold inactive when it is not configured' {
# An unset threshold means "report, do not fail". Defaulting it to 0 would turn
# a single transient lookup failure into a failed run.
Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) }
Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503' }
$config = New-TestRuntimeConfiguration -TargetAttribute $script:target -Rules @(
(New-TestMembershipRule)
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) }
})
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $script:target -Context (New-TestAuditContext)
$outcome.Counters.EvaluationError | Should -BeGreaterThan 0
$outcome.ExitCode | Should -Be 0
}
It 'returns 5 when reconciliation fails' {
Mock Get-PersonaUsers { @(New-TestPopulation -Count 6 -TargetAttribute $script:target) }
Mock Add-PersonaRunResult { $Counters.Processed++ }
$outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext)
$outcome.ExitCode | Should -Be 5
}
It 'prefers 5 over 4 when both conditions hold' {
# A reconciliation failure means the counters cannot be trusted, so the
# threshold check is reading numbers that may be wrong. The engine defect is
# the more serious and more actionable report.
Mock Get-PersonaUsers { @(New-TestPopulation -Count 6 -TargetAttribute $script:target) }
Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503' }
# Processed advances twice per user while only one outcome is tallied: the
# buckets no longer add up AND the error count clears the threshold, so both
# conditions are genuinely true at once.
Mock Add-PersonaRunResult { $Counters.Processed += 2; $Counters.EvaluationError++ }
$config = New-TestRuntimeConfiguration -TargetAttribute $script:target -EvaluationErrorThreshold 1 -Rules @(
(New-TestMembershipRule)
)
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $script:target -Context (New-TestAuditContext)
$outcome.ExitCode | Should -Be 5
}
}
Describe 'Exit codes owned by the entry script' {
BeforeAll {
$script:entryText = Get-Content -LiteralPath $script:entryScript -Raw
}
It 'declares all seven documented codes' {
foreach ($name in @('EXIT_OK', 'EXIT_CONFIG', 'EXIT_AUTH', 'EXIT_ENUMERATION',
'EXIT_DATA', 'EXIT_RECONCILIATION', 'EXIT_UNEXPECTED')) {
$script:entryText | Should -Match "\`$$name\s*=\s*\d"
}
}
It 'assigns each code the value the contract documents' {
$expected = @{
'EXIT_OK' = 0; 'EXIT_CONFIG' = 1; 'EXIT_AUTH' = 2; 'EXIT_ENUMERATION' = 3
'EXIT_DATA' = 4; 'EXIT_RECONCILIATION' = 5; 'EXIT_UNEXPECTED' = 6
}
foreach ($name in $expected.Keys) {
$script:entryText | Should -Match "\`$$name\s*=\s*$($expected[$name])\b"
}
}
It 'returns exit code 1 without connecting when validation fails' {
# The ordering matters as much as the code: FR-002 requires validation to
# complete before any connection is attempted.
$configIndex = $script:entryText.IndexOf('exit $EXIT_CONFIG')
$connectIndex = $script:entryText.IndexOf('Connect-PersonaGraphInteractive')
$configIndex | Should -BeGreaterThan 0
$connectIndex | Should -BeGreaterThan $configIndex
}
It 'maps an authentication failure to code 2' {
$script:entryText | Should -Match '(?s)Connect-PersonaGraphInteractive.*?\$exitCode = \$EXIT_AUTH'
}
It 'falls back to code 6 for an unexpected error' {
$script:entryText | Should -Match '\$exitCode = \$EXIT_UNEXPECTED'
}
It 'writes a RunComplete record even on a fatal error' {
# A run that died at user 400 of 5000 has to leave evidence saying so.
$script:entryText | Should -Match '(?s)finally\s*\{.*Export-PersonaRunReport'
}
}
+106
View File
@@ -0,0 +1,106 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-001: every processed user lands in exactly one outcome bucket.
Exclusivity is what makes reconciliation meaningful. If a user could be both
Matched and EvaluationError, the sum would still be checkable but would no longer
mean anything, and FR-021 would be verifying arithmetic rather than correctness.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
# A rule set that needs membership data, so a failed lookup produces
# EvaluationError and all three buckets are populated in one run.
$script:rules = @(
(New-TestMembershipRule)
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
)
$script:config = New-TestRuntimeConfiguration -TargetAttribute $target -Rules $script:rules
}
Describe 'Exactly one outcome per user (SC-001)' {
BeforeEach {
Mock Write-Host { }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
Mock Get-PersonaUsers { New-TestPopulation -Count 24 -TargetAttribute $script:target }
# Every fourth account fails its membership lookup, producing a genuine mix
# of all three outcomes rather than a run where only one bucket is exercised.
$script:lookup = 0
Mock Get-PersonaGroupMembership {
$script:lookup++
($script:lookup % 4) -eq 0 `
? (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts') `
: (New-PersonaMembershipRecord -AllRetrieved)
}
}
It 'accounts for every processed user across the three outcome buckets' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -ShouldProcessGate { param($t, $d) $false }
$sum = $outcome.Counters.Matched + $outcome.Counters.Unclassified + $outcome.Counters.EvaluationError
$sum | Should -Be $outcome.Counters.Processed
}
It 'populates all three buckets, so the sum is not trivially satisfied' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -ShouldProcessGate { param($t, $d) $false }
$outcome.Counters.Matched | Should -BeGreaterThan 0
$outcome.Counters.Unclassified | Should -BeGreaterThan 0
$outcome.Counters.EvaluationError | Should -BeGreaterThan 0
}
It 'accounts for every processed user across the action buckets too' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -ShouldProcessGate { param($t, $d) $false }
$actions = $outcome.Counters.Unchanged + $outcome.Counters.WouldUpdate +
$outcome.Counters.Updated + $outcome.Counters.UpdateFailed + $outcome.Counters.Skipped
$actions | Should -Be $outcome.Counters.Processed
}
It 'assigns a single outcome value to each decision result' {
$record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'a@example.invalid' -Properties @{ Department = 'Finance' } `
-Membership (New-PersonaMembershipRecord -AllRetrieved)
$result = Resolve-UserPersona -UserRecord $record -Rules $script:rules
@($result.Outcome).Count | Should -Be 1
$result.Outcome | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError')
}
It 'never reports an EvaluationError user as Matched' {
# The specific confusion FR-013 exists to prevent: an unevaluable account
# must never carry a persona, or the preserved value and the calculated value
# would both look authoritative.
$record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000102' `
-UserPrincipalName 'b@example.invalid' -Properties @{ Department = 'Finance' } `
-StoredPersona 'Employee' `
-Membership (New-PersonaMembershipRecord -DirectFailureReason 'lookup failed')
$result = Resolve-UserPersona -UserRecord $record -Rules $script:rules
$result.Outcome | Should -Be 'EvaluationError'
$result.MatchedRuleId | Should -BeNullOrEmpty
$result.CalculatedPersona | Should -BeNullOrEmpty
$result.StoredPersona | Should -Be 'Employee'
}
}
+126
View File
@@ -0,0 +1,126 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-004: @odata.nextLink is followed to exhaustion, and a truncated enumeration
raises rather than returning a partial population.
The second half is the one worth the effort. A partial population reconciles
cleanly, produces a plausible summary, and reports success - so nothing
downstream can tell that half the tenant was never looked at.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
}
Describe 'User enumeration pagination (FR-004)' {
It 'follows nextLink across every page' {
$script:page = 0
Mock Invoke-PersonaGraphRequest {
$script:page++
$users = 1..3 | ForEach-Object {
New-TestGraphUser -Id ('00000000-0000-0000-0000-{0:d12}' -f (($script:page - 1) * 3 + $_)) `
-UserPrincipalName ("user{0}-{1}@example.invalid" -f $script:page, $_)
}
$response = @{ value = $users }
if ($script:page -lt 4) { $response['@odata.nextLink'] = "/v1.0/users?`$skiptoken=page$script:page" }
$response
}
$result = @(Get-PersonaUsers -SelectProperties @('id', 'userPrincipalName'))
$result.Count | Should -Be 12
Should -Invoke Invoke-PersonaGraphRequest -Times 4 -Exactly
}
It 'stops when nextLink is absent rather than looping' {
Mock Invoke-PersonaGraphRequest { @{ value = @(New-TestGraphUser -Id '00000000-0000-0000-0000-000000000001' -UserPrincipalName 'a@example.invalid') } }
$result = @(Get-PersonaUsers -SelectProperties @('id'))
$result.Count | Should -Be 1
Should -Invoke Invoke-PersonaGraphRequest -Times 1 -Exactly
}
It 'raises on a response with no value collection rather than returning what it has' {
$script:page = 0
Mock Invoke-PersonaGraphRequest {
$script:page++
if ($script:page -eq 1) {
return @{
value = @(New-TestGraphUser -Id '00000000-0000-0000-0000-000000000001' -UserPrincipalName 'a@example.invalid')
'@odata.nextLink' = '/v1.0/users?$skiptoken=abc'
}
}
# Page two comes back malformed. Returning page one alone would look like
# a complete, tiny tenant.
@{ error = 'unexpected' }
}
{ @(Get-PersonaUsers -SelectProperties @('id')) } | Should -Throw -ExpectedMessage '*partial population*'
}
It 'raises on a null response' {
Mock Invoke-PersonaGraphRequest { $null }
{ @(Get-PersonaUsers -SelectProperties @('id')) } | Should -Throw
}
It 'requests the maximum page size so a large tenant needs fewer round trips' {
Mock Invoke-PersonaGraphRequest -ParameterFilter { $Uri -match '\$top=999' } -MockWith { @{ value = @() } }
$null = @(Get-PersonaUsers -SelectProperties @('id'))
Should -Invoke Invoke-PersonaGraphRequest -Times 1 -Exactly
}
}
Describe 'Membership pagination' {
It 'follows nextLink and collects only group objects' {
$script:page = 0
Mock Invoke-PersonaGraphRequest {
$script:page++
if ($script:page -eq 1) {
return @{
value = @(
@{ '@odata.type' = '#microsoft.graph.group'; id = '00000000-0000-0000-0000-0000000000a0' }
# An administrative unit arriving on memberOf. Treating it as
# a group ID would never match, which reads as "not a member".
@{ '@odata.type' = '#microsoft.graph.administrativeUnit'; id = '00000000-0000-0000-0000-0000000000e0' }
)
'@odata.nextLink' = '/v1.0/users/x/memberOf?$skiptoken=abc'
}
}
@{ value = @(@{ '@odata.type' = '#microsoft.graph.group'; id = '00000000-0000-0000-0000-0000000000b0' }) }
}
$record = Get-PersonaGroupMembership -UserObjectId '00000000-0000-0000-0000-000000000101' -NeedDirect
$record.DirectRetrieved | Should -BeTrue
$record.DirectGroupObjectIds.Count | Should -Be 2
$record.DirectGroupObjectIds | Should -Contain '00000000-0000-0000-0000-0000000000a0'
$record.DirectGroupObjectIds | Should -Contain '00000000-0000-0000-0000-0000000000b0'
$record.DirectGroupObjectIds | Should -Not -Contain '00000000-0000-0000-0000-0000000000e0'
}
It 'contains a mid-pagination failure to the affected facet instead of returning a short list' {
# The critical case. A truncated membership list looks exactly like a user who
# left a group, and would silently reclassify them (FR-013).
Mock Invoke-PersonaGraphRequest { throw 'Graph 503 after 5 attempts' }
$record = Get-PersonaGroupMembership -UserObjectId '00000000-0000-0000-0000-000000000101' -NeedDirect
$record.DirectRetrieved | Should -BeFalse
$record.DirectFailureReason | Should -Not -BeNullOrEmpty
}
}
+156
View File
@@ -0,0 +1,156 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-021, SC-007: Processed = Matched + Unclassified + EvaluationError at every
summary, and a mismatch is reported as an engine defect.
The forced-mismatch tests are the point. A reconciliation check that only ever
sees correct data proves that the arithmetic works, not that the failure path
does - and the failure path is the only part anyone will ever depend on.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:population = @(New-TestPopulation -Count 15 -TargetAttribute $target)
}
Describe 'Reconciliation arithmetic (FR-021)' {
It 'passes when the outcome buckets account for every processed user' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 7
$counters.Unclassified = 2
$counters.EvaluationError = 1
Test-PersonaReconciliation -Counters $counters | Should -BeTrue
}
It 'fails when a user was processed but landed in no bucket' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 7
$counters.Unclassified = 2
$counters.EvaluationError = 0
Test-PersonaReconciliation -Counters $counters | Should -BeFalse
}
It 'fails when a user was double-counted' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 8
$counters.Unclassified = 2
$counters.EvaluationError = 1
Test-PersonaReconciliation -Counters $counters | Should -BeFalse
}
It 'passes trivially on an empty run' {
Test-PersonaReconciliation -Counters (New-PersonaRunCounter -Rules @()) | Should -BeTrue
}
It 'ignores the action buckets, which would otherwise mask a lost user' {
# Skipped is a catch-all. If reconciliation checked the action buckets, a lost
# user absorbed into Skipped would keep the sums balanced on a broken run.
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 5
$counters.Matched = 5
$counters.Skipped = 99
Test-PersonaReconciliation -Counters $counters | Should -BeTrue
}
}
Describe 'Reconciliation failure detail' {
It 'reports the difference and its sign so a maintainer can tell loss from duplication' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 7
$counters.Unclassified = 2
$counters.EvaluationError = 0
$detail = Get-PersonaReconciliationDetail -Counters $counters
$detail.processed | Should -Be 10
$detail.outcomeTotal | Should -Be 9
$detail.difference | Should -Be 1
$detail.severity | Should -Be 'Error'
$detail.defect | Should -Be 'ReconciliationFailure'
}
It 'reports a negative difference when a user was double-counted' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 9
$counters.Unclassified = 2
(Get-PersonaReconciliationDetail -Counters $counters).difference | Should -Be -1
}
}
Describe 'Reconciliation over a real run (SC-007)' {
BeforeEach {
Mock Get-PersonaUsers { $script:population }
Mock Write-Host { }
}
It 'reconciles at every interim summary and at completion' {
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 5
$info = $null
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -AuditParameters @{ Destination = 'stream' } `
-InformationVariable info
$summaries = Get-CapturedAuditRecord -Captured $info -RecordType 'Summary'
# Three interim boundaries at 5, 10, 15 plus the final summary.
$summaries.Count | Should -BeGreaterThan 1
foreach ($summary in $summaries) {
$summary['reconciliationPassed'] | Should -BeTrue
$summary['processed'] | Should -Be ($summary['matched'] + $summary['unclassified'] + $summary['evaluationError'])
}
}
It 'returns exit code 5 and emits an EngineDefect when reconciliation fails' {
# The failure is forced by making Add-PersonaRunResult drop the outcome tally
# while still counting the user as processed - precisely the bookkeeping defect
# FR-021 exists to catch.
Mock Add-PersonaRunResult { $Counters.Processed++ }
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 0
$info = $null
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -AuditParameters @{ Destination = 'stream' } `
-InformationVariable info
$outcome.ExitCode | Should -Be 5
$defects = Get-CapturedAuditRecord -Captured $info -RecordType 'EngineDefect'
$defects.Count | Should -BeGreaterThan 0
$defects[0]['defect'] | Should -Be 'ReconciliationFailure'
$defects[0]['severity'] | Should -Be 'Error'
}
It 'marks the summary record itself as failed when reconciliation fails' {
Mock Add-PersonaRunResult { $Counters.Processed++ }
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 0
$info = $null
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -AuditParameters @{ Destination = 'stream' } `
-InformationVariable info
(Get-CapturedAuditRecord -Captured $info -RecordType 'Summary')[0]['reconciliationPassed'] | Should -BeFalse
}
}
+181
View File
@@ -0,0 +1,181 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'src/Normalization/New-PersonaMembershipRecord.ps1')
. (Join-Path $repoRoot 'src/Normalization/New-PersonaUserRecord.ps1')
. (Join-Path $repoRoot 'src/Configuration/New-PersonaValidationFinding.ps1')
}
Describe 'New-PersonaMembershipRecord' {
Context 'retrieval flags default to unretrieved' {
It 'defaults every facet to $false so an unset flag means unknown, never "not a member"' {
# The safety-critical default. If any of these flip, a failed group
# lookup satisfies notMemberOf and privileged accounts get misclassified.
$record = New-PersonaMembershipRecord
$record.DirectRetrieved | Should -BeFalse
$record.TransitiveRetrieved | Should -BeFalse
$record.RolesRetrieved | Should -BeFalse
}
It 'sets a facet only when explicitly requested' {
$record = New-PersonaMembershipRecord -DirectRetrieved
$record.DirectRetrieved | Should -BeTrue
$record.TransitiveRetrieved | Should -BeFalse
}
It 'AllRetrieved sets every facet' {
$record = New-PersonaMembershipRecord -AllRetrieved
$record.DirectRetrieved | Should -BeTrue
$record.TransitiveRetrieved | Should -BeTrue
$record.RolesRetrieved | Should -BeTrue
}
}
Context 'independent facets (RE-007)' {
It 'carries direct and transitive membership simultaneously' {
# The reason this record has three facets rather than one mode: a rule
# set may ask for direct membership in one rule and transitive in
# another, and a single-mode record cannot answer both.
$record = New-PersonaMembershipRecord `
-DirectGroupObjectIds @('00000000-0000-0000-0000-0000000000a0') `
-TransitiveGroupObjectIds @('00000000-0000-0000-0000-0000000000a0', '00000000-0000-0000-0000-0000000000b0') `
-AllRetrieved
$record.DirectGroupObjectIds | Should -HaveCount 1
$record.TransitiveGroupObjectIds | Should -HaveCount 2
}
It 'contains a failure to the facet that failed' {
# A transitive lookup that times out must not make direct-membership
# conditions unevaluable — otherwise one slow endpoint becomes a
# tenant-wide outage.
$record = New-PersonaMembershipRecord -DirectRetrieved -RolesRetrieved -TransitiveFailureReason 'Graph 503'
$record.DirectRetrieved | Should -BeTrue
$record.RolesRetrieved | Should -BeTrue
$record.TransitiveRetrieved | Should -BeFalse
$record.TransitiveFailureReason | Should -Be 'Graph 503'
}
}
Context 'shape' {
It 'always returns arrays, never $null, for the identifier collections' {
$record = New-PersonaMembershipRecord -AllRetrieved
, $record.DirectGroupObjectIds | Should -BeOfType [System.Array]
, $record.TransitiveGroupObjectIds | Should -BeOfType [System.Array]
, $record.DirectoryRoleIds | Should -BeOfType [System.Array]
}
It 'refuses to be both retrieved and failed on the same facet' {
{ New-PersonaMembershipRecord -DirectRetrieved -DirectFailureReason 'boom' } |
Should -Throw '*cannot be both*'
}
It 'refuses AllRetrieved alongside any failure reason' {
{ New-PersonaMembershipRecord -AllRetrieved -RolesFailureReason 'boom' } |
Should -Throw '*cannot be both*'
}
}
}
Describe 'New-PersonaUserRecord' {
Context 'required identity fields' {
It 'requires a non-empty AccountObjectId' {
{ New-PersonaUserRecord -AccountObjectId '' -UserPrincipalName 'a@example.invalid' } |
Should -Throw
}
It 'requires a non-empty UserPrincipalName' {
{ New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' -UserPrincipalName '' } |
Should -Throw
}
}
Context 'property bag' {
BeforeAll {
$script:record = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'alex.employee@example.invalid' `
-UserType 'Member' `
-Properties @{ Department = 'Finance'; CompanyName = $null }
}
It 'looks up properties case-insensitively (RE-006)' {
$record.Properties['department'] | Should -Be 'Finance'
$record.Properties['DEPARTMENT'] | Should -Be 'Finance'
}
It 'returns $null for an absent property rather than throwing (FR-012)' {
$record.Properties['NoSuchProperty'] | Should -BeNullOrEmpty
}
It 'preserves an explicit null value' {
$record.Properties['CompanyName'] | Should -BeNullOrEmpty
}
It 'exposes intrinsic fields as addressable properties' {
$record.Properties['UserPrincipalName'] | Should -Be 'alex.employee@example.invalid'
$record.Properties['UserType'] | Should -Be 'Member'
$record.Properties['AccountEnabled'] | Should -Be $true
}
It 'lets an explicit property override an intrinsic default' {
$overridden = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'a@example.invalid' `
-Properties @{ UserType = 'Overridden' }
$overridden.Properties['UserType'] | Should -Be 'Overridden'
}
}
Context 'membership default' {
It 'never leaves Membership null' {
$record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' -UserPrincipalName 'a@example.invalid'
$record.Membership | Should -Not -BeNullOrEmpty
}
It 'defaults to a fully unretrieved record so membership conditions yield Unknown' {
# An absent lookup is unknown, never "member of nothing". A rule set
# with no membership conditions never consults this; one that does must
# get EvaluationError rather than a fabricated non-match.
$record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' -UserPrincipalName 'a@example.invalid'
$record.Membership.DirectRetrieved | Should -BeFalse
$record.Membership.TransitiveRetrieved | Should -BeFalse
$record.Membership.RolesRetrieved | Should -BeFalse
}
}
}
Describe 'New-PersonaValidationFinding' {
It 'derives the layer from the code namespace' {
(New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' -Location 'rules[0]' -Description 'd' -SuggestedResolution 'r').Layer |
Should -Be 'Semantic'
(New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-001' -Location 'engine' -Description 'd' -SuggestedResolution 'r').Layer |
Should -Be 'Safety'
}
It 'rejects a malformed finding code' {
{ New-PersonaValidationFinding -Severity Error -Code 'BAD-001' -Location 'x' -Description 'd' -SuggestedResolution 'r' } |
Should -Throw
}
It 'requires a suggested resolution' {
{ New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' -Location 'x' -Description 'd' -SuggestedResolution '' } |
Should -Throw
}
It 'rejects an unrecognized severity' {
{ New-PersonaValidationFinding -Severity Critical -Code 'PE-SEM-001' -Location 'x' -Description 'd' -SuggestedResolution 'r' } |
Should -Throw
}
}
+111
View File
@@ -0,0 +1,111 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
NFR-003 and the OTD-007 retry table.
The non-retryable cases carry the operational weight. A 403 retried five times per
account turns an instant authorization failure into a long, expensive one, and
hammers a tenant that is already refusing - so status extraction has to work even
when the code appears only in the exception message, which is how
Invoke-MgGraphRequest reports several of its failures.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
function New-StatusError {
param([int] $Status, [string] $Reason = 'Failed')
[System.Management.Automation.ErrorRecord]::new(
[System.Exception]::new("Response status code does not indicate success: $Status ($Reason)."),
'GraphError', [System.Management.Automation.ErrorCategory]::InvalidResult, $null)
}
}
Describe 'Status extraction from an error record' {
It 'reads a status embedded only in the message' {
Get-PersonaGraphStatusCode -ErrorRecord (New-StatusError -Status 403 -Reason 'Forbidden') | Should -Be 403
}
It 'reads each status the retry table names' {
foreach ($status in @(400, 401, 403, 404, 409, 429, 500, 502, 503, 504)) {
Get-PersonaGraphStatusCode -ErrorRecord (New-StatusError -Status $status) | Should -Be $status
}
}
It 'returns null for a transport failure with no status anywhere' {
$record = [System.Management.Automation.ErrorRecord]::new(
[System.Exception]::new('The operation was canceled.'),
'Timeout', [System.Management.Automation.ErrorCategory]::OperationTimeout, $null)
Get-PersonaGraphStatusCode -ErrorRecord $record | Should -BeNullOrEmpty
}
It 'does not mistake an unrelated three-digit number for a status' {
$record = [System.Management.Automation.ErrorRecord]::new(
[System.Exception]::new('Processed 250 objects before the connection dropped.'),
'Transport', [System.Management.Automation.ErrorCategory]::ConnectionError, $null)
# 250 is outside the HTTP error range, so it is not treated as a status and
# the failure stays retryable - which is the correct answer for a dropped
# connection.
Get-PersonaGraphStatusCode -ErrorRecord $record | Should -BeNullOrEmpty
}
}
Describe 'Retry policy (OTD-007)' {
# One It per status rather than a loop: Should -Invoke counts across the whole It
# block, so a loop would accumulate calls and the second iteration would fail on
# the first one's arithmetic.
It 'never retries status <_>' -ForEach @(400, 401, 403, 404, 409) {
$status = $_
Mock Invoke-MgGraphRequest { throw "Response status code does not indicate success: $status (Failed)." }.GetNewClosure()
{ Invoke-PersonaGraphRequest -Uri '/v1.0/users' -BaseDelayMs 1 } | Should -Throw
Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly -Because "status $status is a client-side defect that retrying would only hide"
}
It 'retries a retryable status up to the attempt limit' {
Mock Invoke-MgGraphRequest { throw 'Response status code does not indicate success: 503 (Service Unavailable).' }
{ Invoke-PersonaGraphRequest -Uri '/v1.0/users' -MaxAttempts 3 -BaseDelayMs 1 } | Should -Throw
Should -Invoke Invoke-MgGraphRequest -Times 3 -Exactly
}
It 'retries a transport failure that carries no status' {
Mock Invoke-MgGraphRequest { throw 'The operation was canceled.' }
{ Invoke-PersonaGraphRequest -Uri '/v1.0/users' -MaxAttempts 2 -BaseDelayMs 1 } | Should -Throw
Should -Invoke Invoke-MgGraphRequest -Times 2 -Exactly
}
It 'returns as soon as an attempt succeeds' {
$script:attempts = 0
Mock Invoke-MgGraphRequest {
$script:attempts++
if ($script:attempts -lt 3) { throw 'Response status code does not indicate success: 429 (Too Many Requests).' }
@{ value = @('ok') }
}
$result = Invoke-PersonaGraphRequest -Uri '/v1.0/users' -BaseDelayMs 1
$result['value'] | Should -Be @('ok')
Should -Invoke Invoke-MgGraphRequest -Times 3 -Exactly
}
It 'passes the body through unchanged as JSON' {
# SC-005 depends on this: the body a test captures must be the body sent.
$script:sentBody = $null
Mock Invoke-MgGraphRequest { $script:sentBody = $Body; @{} }
$null = Invoke-PersonaGraphRequest -Uri '/v1.0/users/x' -Method 'PATCH' -Body @{ 'extension_x_Persona' = 'Employee' }
($script:sentBody | ConvertFrom-Json).'extension_x_Persona' | Should -Be 'Employee'
}
}
+127
View File
@@ -0,0 +1,127 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-019, FR-020: interim summaries appear at the configured interval, an interval of
0 suppresses them, and a final summary appears in every case.
The zero case is the one that matters. Suppressing interim output is a reasonable
thing to want on a large run; suppressing the final summary too would leave an
operator with per-user lines and no totals.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:population = @(New-TestPopulation -Count 20 -TargetAttribute $target)
}
Describe 'Summary interval semantics (FR-019, FR-020)' {
BeforeEach {
Mock Get-PersonaUsers { $script:population }
Mock Write-Host { }
Mock Write-PersonaSummary { }
}
It 'emits a final summary and no interim summaries when the interval is 0' {
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 0
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext)
Should -Invoke Write-PersonaSummary -Times 1 -Exactly
Should -Invoke Write-PersonaSummary -Times 1 -Exactly -ParameterFilter { $SummaryType -eq 'Final' }
Should -Invoke Write-PersonaSummary -Times 0 -Exactly -ParameterFilter { $SummaryType -eq 'Interim' }
}
It 'emits an interim summary at each interval boundary' {
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 5
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext)
# 20 users at an interval of 5: boundaries at 5, 10, 15, 20.
Should -Invoke Write-PersonaSummary -Times 4 -Exactly -ParameterFilter { $SummaryType -eq 'Interim' }
Should -Invoke Write-PersonaSummary -Times 1 -Exactly -ParameterFilter { $SummaryType -eq 'Final' }
}
It 'honours the documented default of 25' {
# 20 users at the default interval: no boundary reached, so only the final
# summary appears. This is the shape of a small run.
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 25
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext)
Should -Invoke Write-PersonaSummary -Times 0 -Exactly -ParameterFilter { $SummaryType -eq 'Interim' }
Should -Invoke Write-PersonaSummary -Times 1 -Exactly -ParameterFilter { $SummaryType -eq 'Final' }
}
It 'emits one interim summary per user at an interval of 1' {
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 1
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext)
Should -Invoke Write-PersonaSummary -Times 20 -Exactly -ParameterFilter { $SummaryType -eq 'Interim' }
}
It 'emits a final summary even when the population is empty' {
Mock Get-PersonaUsers { @() }
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 5
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext)
Should -Invoke Write-PersonaSummary -Times 1 -Exactly -ParameterFilter { $SummaryType -eq 'Final' }
$outcome.Counters.Processed | Should -Be 0
}
}
Describe 'Summary content (FR-019)' {
It 'lists every rule, including disabled ones and those with no matches' {
# A rule that never fired and a rule that is not in the configuration look
# identical if zero-match rules are omitted, and that difference is exactly
# what an operator investigating a missing classification needs.
$rules = @(
[pscustomobject]@{ id = 'RULE-0010-OFF'; name = 'Disabled rule'; enabled = $false; priority = 10; persona = 'Guest'
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }) } }
[pscustomobject]@{ id = 'RULE-0020-NEVER'; name = 'Never matches'; enabled = $true; priority = 20; persona = 'Tier0-Admin'
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'NoSuchDepartment' }) } }
[pscustomobject]@{ id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) } }
)
$counters = New-PersonaRunCounter -Rules $rules
$counters.RuleCounts.Count | Should -Be 3
($counters.RuleCounts | Where-Object RuleId -EQ 'RULE-0010-OFF').Enabled | Should -BeFalse
($counters.RuleCounts | Where-Object RuleId -EQ 'RULE-0020-NEVER').Matches | Should -Be 0
}
It 'orders rules by priority so the table reads in evaluation order' {
$rules = @(
[pscustomobject]@{ id = 'C'; name = 'C'; enabled = $true; priority = 900; persona = 'Employee'; match = $null }
[pscustomobject]@{ id = 'A'; name = 'A'; enabled = $true; priority = 10; persona = 'Guest'; match = $null }
[pscustomobject]@{ id = 'B'; name = 'B'; enabled = $true; priority = 30; persona = 'Tier0-Admin'; match = $null }
)
$counters = New-PersonaRunCounter -Rules $rules
($counters.RuleCounts | ForEach-Object RuleId) -join ',' | Should -Be 'A,B,C'
}
It 'increments the matched rule and no other' {
$rules = @(
[pscustomobject]@{ id = 'RULE-A'; name = 'A'; enabled = $true; priority = 10; persona = 'Guest'; match = $null }
[pscustomobject]@{ id = 'RULE-B'; name = 'B'; enabled = $true; priority = 20; persona = 'Employee'; match = $null }
)
$counters = New-PersonaRunCounter -Rules $rules
Add-PersonaRunResult -Counters $counters -Result ([pscustomobject]@{
Outcome = 'Matched'; MatchedRuleId = 'RULE-B'; Action = 'Unchanged' })
($counters.RuleCounts | Where-Object RuleId -EQ 'RULE-A').Matches | Should -Be 0
($counters.RuleCounts | Where-Object RuleId -EQ 'RULE-B').Matches | Should -Be 1
}
}