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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user