#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent foreach ($f in @( 'src/Normalization/New-PersonaMembershipRecord.ps1' 'src/Normalization/New-PersonaUserRecord.ps1' 'src/RuleEngine/Test-PersonaCondition.ps1' 'src/RuleEngine/Test-PersonaConditionGroup.ps1' 'src/RuleEngine/Test-PersonaRule.ps1' 'src/RuleEngine/Resolve-UserPersona.ps1' )) { . (Join-Path $repoRoot $f) } $script:tier0 = '00000000-0000-0000-0000-0000000000a0' function New-UserWithFailedLookup { param([string] $StoredPersona = 'Employee') New-PersonaUserRecord ` -AccountObjectId '00000000-0000-0000-0000-000000000101' ` -UserPrincipalName 'alex.employee@example.invalid' ` -Properties @{ Department = 'Finance' } ` -StoredPersona $StoredPersona ` -Membership (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts') } function New-UserWithGoodLookup { New-PersonaUserRecord ` -AccountObjectId '00000000-0000-0000-0000-000000000102' ` -UserPrincipalName 'blair.ok@example.invalid' ` -Properties @{ Department = 'Finance' } ` -StoredPersona 'Employee' ` -Membership (New-PersonaMembershipRecord -AllRetrieved) } $script:membershipRule = [pscustomobject]@{ id = 'R-030'; priority = 30; persona = 'Tier0-Admin'; enabled = $true match = [pscustomobject]@{ operator = 'all' conditions = @([pscustomobject]@{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0) }) } } $script:catchAllRule = [pscustomobject]@{ id = 'R-900'; priority = 900; persona = 'Employee'; enabled = $true match = [pscustomobject]@{ operator = 'all' conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) } } } Describe 'EvaluationError outcome (FR-013, FR-014)' { It 'is produced when required membership data could not be retrieved' { $result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($membershipRule) $result.Outcome | Should -Be 'EvaluationError' } It 'preserves the stored persona' { $result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup -StoredPersona 'Tier0-Admin') -Rules @($membershipRule) $result.StoredPersona | Should -Be 'Tier0-Admin' } It 'leaves CalculatedPersona null so nothing can be written' { $result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($membershipRule) $result.CalculatedPersona | Should -BeNullOrEmpty } It 'records a reason naming the rule that could not be evaluated' { $result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($membershipRule) $result.EvaluationErrorReason | Should -Not -BeNullOrEmpty $result.EvaluationErrorReason | Should -BeLike '*R-030*' } It 'stops evaluation rather than falling through to a lower-priority rule' { # The critical case. Falling through would assign Employee to an account # that may in truth be a Tier 0 administrator — a silent privilege # downgrade, which is exactly what FR-013 exists to prevent. $result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($membershipRule, $catchAllRule) $result.Outcome | Should -Be 'EvaluationError' $result.CalculatedPersona | Should -Not -Be 'Employee' $result.RulesEvaluated | Should -Be 1 } It 'still matches when a higher-priority rule resolves before the unknown one' { # An unknown rule at priority 30 is irrelevant if priority 10 already matched. $earlyMatch = [pscustomobject]@{ id = 'R-010'; priority = 10; persona = 'Guest'; enabled = $true match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Finance' }) } } $result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($earlyMatch, $membershipRule) $result.Outcome | Should -Be 'Matched' $result.CalculatedPersona | Should -Be 'Guest' } It 'does not affect a user whose lookup succeeded' { # Per-user isolation: one account's data failure must not contaminate another. $result = Resolve-UserPersona -UserRecord (New-UserWithGoodLookup) -Rules @($membershipRule, $catchAllRule) $result.Outcome | Should -Be 'Matched' $result.CalculatedPersona | Should -Be 'Employee' } It 'processes a mixed population without one failure stopping the others' { $population = @((New-UserWithFailedLookup), (New-UserWithGoodLookup)) $results = $population | ForEach-Object { Resolve-UserPersona -UserRecord $_ -Rules @($membershipRule, $catchAllRule) } $results | Should -HaveCount 2 ($results | Where-Object Outcome -EQ 'EvaluationError') | Should -HaveCount 1 ($results | Where-Object Outcome -EQ 'Matched') | Should -HaveCount 1 } It 'is produced when the condition tree exceeds the depth limit' { $deep = [pscustomobject]@{ id = 'R-050'; priority = 50; persona = 'Employee'; enabled = $true match = [pscustomobject]@{ operator = 'all' conditions = @([pscustomobject]@{ operator = 'all' conditions = @([pscustomobject]@{ operator = 'all' conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Finance' }) }) }) } } $result = Resolve-UserPersona -UserRecord (New-UserWithGoodLookup) -Rules @($deep) -MaxDepth 2 $result.Outcome | Should -Be 'EvaluationError' } }