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,130 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
Editor exit codes 0-4 (cli-edit-persona-engine-config.md).
|
||||
|
||||
An exit code is the only thing a pipeline sees. Every code has to be reachable and
|
||||
has to mean what the contract says - in particular, code 3 (file unreadable) and
|
||||
code 4 (schema unusable) must not collapse into code 1, or a missing schema file
|
||||
gets reported to a rule author as "your configuration is invalid".
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
|
||||
|
||||
$script:editor = Join-Path $repoRoot 'Edit-PersonaEngineConfig.ps1'
|
||||
$script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-ec-{0}" -f [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory -Path $script:scratch -Force
|
||||
|
||||
function Invoke-Editor {
|
||||
param([string[]] $ArgumentList)
|
||||
|
||||
$exe = (Get-Process -Id $PID).Path
|
||||
$null = & $exe -NoProfile -NonInteractive -File $script:editor @ArgumentList 2>&1
|
||||
$LASTEXITCODE
|
||||
}
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Describe 'Editor exit codes' {
|
||||
|
||||
It 'returns 0 for a valid configuration' {
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
|
||||
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') | Should -Be 0
|
||||
}
|
||||
|
||||
It 'returns 1 when Error findings are present' {
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.rules[1].persona = 'Undeclared-Persona'
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
|
||||
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') | Should -Be 1
|
||||
}
|
||||
|
||||
It 'returns 2 for Warning findings under -TreatWarningsAsErrors' {
|
||||
# A pinned global mode plus a per-condition override: a Warning, not an Error.
|
||||
# VR-005 escalates it only when the caller asks.
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.dataSources.groups.membershipMode = 'direct'
|
||||
$document.rules += @{
|
||||
id = 'RULE-0030-TIER0'; name = 'Tier 0'; description = 'Tier 0 group members.'
|
||||
enabled = $true; priority = 30; persona = 'Tier0-Admin'
|
||||
match = @{
|
||||
operator = 'all'
|
||||
conditions = @(@{
|
||||
type = 'membership'; operator = 'memberOf'; membershipMode = 'transitive'
|
||||
groupObjectIds = @('00000000-0000-0000-0000-0000000000a0')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
|
||||
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') | Should -Be 0
|
||||
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive', '-TreatWarningsAsErrors') | Should -Be 2
|
||||
}
|
||||
|
||||
It 'returns 3 when the configuration file is absent' {
|
||||
Invoke-Editor -ArgumentList @('-ConfigPath', (Join-Path $script:scratch 'absent.json'), '-ValidateOnly', '-NonInteractive') | Should -Be 3
|
||||
}
|
||||
|
||||
It 'returns 4 when the schema file is absent' {
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
|
||||
Invoke-Editor -ArgumentList @(
|
||||
'-ConfigPath', $path, '-ValidateOnly', '-NonInteractive',
|
||||
'-SchemaPath', (Join-Path $script:scratch 'no-schema.json')) | Should -Be 4
|
||||
}
|
||||
|
||||
It 'returns 4 when the schema file exists but cannot be parsed' {
|
||||
# Distinct from code 1 on purpose. The configuration was never actually
|
||||
# checked, so calling it invalid would be a guess.
|
||||
$brokenSchema = Join-Path $script:scratch 'broken.json'
|
||||
Set-Content -LiteralPath $brokenSchema -Value '{ not json' -Encoding utf8NoBOM
|
||||
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
|
||||
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive', '-SchemaPath', $brokenSchema) | Should -Be 4
|
||||
}
|
||||
|
||||
It 'prefers code 3 over code 1 when the file cannot be read at all' {
|
||||
# An unreadable file produces a PE-SYN Error finding too. Reporting code 1
|
||||
# would tell the author their rules are wrong when the file never opened.
|
||||
Invoke-Editor -ArgumentList @('-ConfigPath', $script:scratch, '-ValidateOnly', '-NonInteractive') | Should -Be 3
|
||||
}
|
||||
|
||||
It 'reaches every documented code across the corpus' {
|
||||
$reached = [System.Collections.Generic.HashSet[int]]::new()
|
||||
|
||||
$valid = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $valid, '-ValidateOnly', '-NonInteractive')))
|
||||
|
||||
$invalidDoc = New-TestConfigurationDocument
|
||||
$invalidDoc.rules[1].persona = 'Undeclared-Persona'
|
||||
$invalid = Save-TestConfiguration -Document $invalidDoc -Directory $script:scratch
|
||||
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $invalid, '-ValidateOnly', '-NonInteractive')))
|
||||
|
||||
$warnDoc = New-TestConfigurationDocument
|
||||
$warnDoc.dataSources.groups.membershipMode = 'direct'
|
||||
$warnDoc.rules += @{
|
||||
id = 'RULE-0030-TIER0'; name = 'Tier 0'; description = 'Tier 0 group members.'
|
||||
enabled = $true; priority = 30; persona = 'Tier0-Admin'
|
||||
match = @{ operator = 'all'; conditions = @(@{
|
||||
type = 'membership'; operator = 'memberOf'; membershipMode = 'transitive'
|
||||
groupObjectIds = @('00000000-0000-0000-0000-0000000000a0') }) }
|
||||
}
|
||||
$warn = Save-TestConfiguration -Document $warnDoc -Directory $script:scratch
|
||||
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $warn, '-ValidateOnly', '-NonInteractive', '-TreatWarningsAsErrors')))
|
||||
|
||||
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', (Join-Path $script:scratch 'absent.json'), '-ValidateOnly', '-NonInteractive')))
|
||||
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $valid, '-ValidateOnly', '-NonInteractive', '-SchemaPath', (Join-Path $script:scratch 'no-schema.json'))))
|
||||
|
||||
0..4 | ForEach-Object { $reached | Should -Contain $_ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
VR-001: the four layers run in order and stop at the first that produces errors.
|
||||
|
||||
The reason is signal, not speed. A document missing a required section produces a
|
||||
cascade of consequent semantic errors, and the author then has to guess which one
|
||||
is the cause. Stopping at the structural failure reports the one thing that is
|
||||
actually wrong.
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
|
||||
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
|
||||
|
||||
$script:schema = Join-Path $repoRoot 'config/persona-engine.schema.json'
|
||||
$script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-layers-{0}" -f [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory -Path $script:scratch -Force
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Describe 'Layer ordering and fail-fast (VR-001)' {
|
||||
|
||||
It 'stops at layer 1 for malformed JSON' {
|
||||
$path = Join-Path $script:scratch 'malformed.json'
|
||||
Set-Content -LiteralPath $path -Value '{ "configVersion": "1.0.0", ' -Encoding utf8NoBOM
|
||||
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
$result.IsValid | Should -BeFalse
|
||||
$result.StoppedAtLayer | Should -Be 'Syntax'
|
||||
$result.Findings.Code | Should -Contain 'PE-SYN-003'
|
||||
}
|
||||
|
||||
It 'reports a missing file at layer 1 without attempting to parse it' {
|
||||
$result = Test-PersonaConfiguration -Path (Join-Path $script:scratch 'absent.json') -SchemaPath $script:schema
|
||||
|
||||
$result.StoppedAtLayer | Should -Be 'Syntax'
|
||||
$result.Findings.Code | Should -Contain 'PE-SYN-001'
|
||||
}
|
||||
|
||||
It 'stops at layer 2 for a structurally invalid document, before semantic checks run' {
|
||||
# The document below also has a semantic defect - a duplicate rule ID - which
|
||||
# must NOT appear, because layer 3 never ran.
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.rules[1].id = $document.rules[0].id
|
||||
$document.engine.Remove('approvedWritableAttributes')
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
$result.IsValid | Should -BeFalse
|
||||
$result.StoppedAtLayer | Should -Be 'Schema'
|
||||
$result.Findings.Code | Should -Contain 'PE-SCH-001'
|
||||
$result.Findings.Code | Should -Not -Contain 'PE-SEM-001'
|
||||
}
|
||||
|
||||
It 'stops at layer 3 for a semantic error, before safety checks run' {
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.rules[1].id = $document.rules[0].id
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
$result.StoppedAtLayer | Should -Be 'Semantic'
|
||||
$result.Findings.Code | Should -Contain 'PE-SEM-001'
|
||||
@($result.Findings | Where-Object Layer -EQ 'Safety') | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'reaches layer 4 when layers 1 to 3 are clean' {
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
$result.IsValid | Should -BeTrue
|
||||
$result.StoppedAtLayer | Should -BeNullOrEmpty
|
||||
@($result.Findings | Where-Object Layer -EQ 'Safety') | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'skips layer 4 on request without claiming it passed' {
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema -SkipSafety
|
||||
|
||||
@($result.Findings | Where-Object Layer -EQ 'Safety') | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'A schema that cannot be used is never reported as a pass (V-5a)' {
|
||||
|
||||
It 'flags an unparseable schema rather than trusting the $true return value' {
|
||||
# Test-Json returns $true here. Trusting it would validate every configuration
|
||||
# against a schema that never ran.
|
||||
$brokenSchema = Join-Path $script:scratch 'broken-schema.json'
|
||||
Set-Content -LiteralPath $brokenSchema -Value '{ not json' -Encoding utf8NoBOM
|
||||
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $brokenSchema
|
||||
|
||||
$result.IsValid | Should -BeFalse
|
||||
$result.SchemaUnusable | Should -BeTrue
|
||||
$result.Findings.Code | Should -Contain 'PE-SCH-003'
|
||||
}
|
||||
|
||||
It 'flags a missing schema file separately from an invalid configuration' {
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath (Join-Path $script:scratch 'no-such-schema.json')
|
||||
|
||||
$result.SchemaUnusable | Should -BeTrue
|
||||
$result.Findings.Code | Should -Contain 'PE-SCH-002'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'The shipped example configuration passes every layer' {
|
||||
|
||||
It 'validates cleanly against the shipped schema' {
|
||||
# If the example the documentation points at cannot pass its own validator,
|
||||
# every reader's first run fails.
|
||||
$result = Test-PersonaConfiguration -Path (Join-Path $repoRoot 'config/persona-engine.example.json')
|
||||
|
||||
$result.IsValid | Should -BeTrue
|
||||
$result.ErrorCount | Should -Be 0
|
||||
$result.WarningCount | Should -Be 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
SC-010: -NonInteractive never prompts and never hangs.
|
||||
|
||||
Each case runs the editor in a child pwsh with stdin redirected from an empty
|
||||
file, which is what a build agent gives it. A tool that prompts there does not
|
||||
fail - it blocks until the job times out, and the pipeline reports an
|
||||
infrastructure problem rather than a bad configuration.
|
||||
|
||||
A wall-clock timeout is the assertion. That makes these the slowest tests in the
|
||||
suite, and there is no cheaper way to prove the absence of a hang: inspecting the
|
||||
source for Read-Host would only prove that one spelling of prompting is absent.
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
|
||||
|
||||
$script:editor = Join-Path $repoRoot 'Edit-PersonaEngineConfig.ps1'
|
||||
$script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-ni-{0}" -f [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory -Path $script:scratch -Force
|
||||
|
||||
$script:emptyStdin = Join-Path $script:scratch 'empty.txt'
|
||||
Set-Content -LiteralPath $script:emptyStdin -Value '' -NoNewline
|
||||
|
||||
function Invoke-EditorWithClosedStdin {
|
||||
<#
|
||||
Runs the editor with stdin from an empty file and a hard timeout.
|
||||
Returns the exit code, or -1 if it had to be killed.
|
||||
#>
|
||||
param(
|
||||
[string[]] $ArgumentList,
|
||||
[int] $TimeoutSeconds = 60
|
||||
)
|
||||
|
||||
$stdout = Join-Path $script:scratch ("out-{0}.txt" -f [guid]::NewGuid().ToString('N'))
|
||||
$stderr = Join-Path $script:scratch ("err-{0}.txt" -f [guid]::NewGuid().ToString('N'))
|
||||
|
||||
$process = Start-Process -FilePath (Get-Process -Id $PID).Path `
|
||||
-ArgumentList (@('-NoProfile', '-NonInteractive', '-File', $script:editor) + $ArgumentList) `
|
||||
-RedirectStandardInput $script:emptyStdin `
|
||||
-RedirectStandardOutput $stdout `
|
||||
-RedirectStandardError $stderr `
|
||||
-PassThru -WindowStyle Hidden
|
||||
|
||||
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
|
||||
$process.Kill($true)
|
||||
return [pscustomobject]@{ ExitCode = -1; Output = 'TIMED OUT'; TimedOut = $true }
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
ExitCode = $process.ExitCode
|
||||
Output = (Get-Content -LiteralPath $stdout -Raw -ErrorAction SilentlyContinue)
|
||||
Error = (Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue)
|
||||
TimedOut = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Describe 'Non-interactive mode never prompts or hangs (SC-010)' {
|
||||
|
||||
It 'completes on a valid configuration with stdin closed' {
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
|
||||
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive')
|
||||
|
||||
$run.TimedOut | Should -BeFalse
|
||||
$run.ExitCode | Should -Be 0
|
||||
}
|
||||
|
||||
It 'completes on an invalid configuration with stdin closed' {
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.rules[1].id = $document.rules[0].id
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
|
||||
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive')
|
||||
|
||||
$run.TimedOut | Should -BeFalse
|
||||
$run.ExitCode | Should -Be 1
|
||||
}
|
||||
|
||||
It 'completes with -NonInteractive alone, without -ValidateOnly' {
|
||||
# The case most likely to regress: -NonInteractive must short-circuit before
|
||||
# the editor loop even when the caller did not also ask for validate-only.
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
|
||||
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-NonInteractive')
|
||||
|
||||
$run.TimedOut | Should -BeFalse
|
||||
$run.ExitCode | Should -Be 0
|
||||
}
|
||||
|
||||
It 'completes when the configuration file does not exist' {
|
||||
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', (Join-Path $script:scratch 'absent.json'), '-NonInteractive')
|
||||
|
||||
$run.TimedOut | Should -BeFalse
|
||||
$run.ExitCode | Should -Be 3
|
||||
}
|
||||
|
||||
It 'completes when running synthetic rule tests' {
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
|
||||
$run = Invoke-EditorWithClosedStdin -ArgumentList @(
|
||||
'-ConfigPath', $path, '-NonInteractive', '-TestDataPath', (Join-Path $repoRoot 'tests/TestData'))
|
||||
|
||||
$run.TimedOut | Should -BeFalse
|
||||
$run.ExitCode | Should -Be 0
|
||||
}
|
||||
|
||||
It 'emits no prompt text on the output stream' {
|
||||
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
|
||||
|
||||
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-NonInteractive')
|
||||
|
||||
$run.Output | Should -Not -Match 'Choice'
|
||||
$run.Output | Should -Not -Match 'configuration editor'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
SC-009, VR-003 half: every safety condition produces a finding.
|
||||
|
||||
Layer 3 asks whether a configuration makes sense. These tests are about layer 4,
|
||||
which asks what happens to the directory if it runs - a coherent configuration can
|
||||
still be a silent no-op or a change nobody declared.
|
||||
|
||||
Several findings change severity with -EnforcementEnabled. Both cases are asserted
|
||||
for each: the same configuration carries very different risk in preview and in
|
||||
enforcement, and a validator that ignored the difference would either block
|
||||
harmless previews or wave through real writes.
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
|
||||
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
|
||||
|
||||
$script:corpus = Join-Path $repoRoot 'tests/TestData/InvalidConfigs'
|
||||
$script:baseline = Join-Path $script:corpus 'baseline-deployed.json'
|
||||
|
||||
function Get-SafetyFinding {
|
||||
param(
|
||||
[string] $Fixture,
|
||||
[string] $Code,
|
||||
[switch] $EnforcementEnabled,
|
||||
[string] $PreviousConfigPath
|
||||
)
|
||||
|
||||
$document = Get-Content -LiteralPath (Join-Path $script:corpus "$Fixture.json") -Raw | ConvertFrom-Json -Depth 32
|
||||
|
||||
$params = @{ Document = $document; EnforcementEnabled = $EnforcementEnabled }
|
||||
if ($PreviousConfigPath) { $params['PreviousConfigPath'] = $PreviousConfigPath }
|
||||
|
||||
@(Test-PersonaConfigurationSafety @params | Where-Object Code -EQ $Code)
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Safety validation, VR-003 conditions (SC-009)' {
|
||||
|
||||
It 'detects a blank target attribute and blocks it under enforcement' {
|
||||
$findings = Get-SafetyFinding -Fixture 'PE-SAF-001-blank-target-production' -Code 'PE-SAF-001' -EnforcementEnabled
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
}
|
||||
|
||||
It 'reports the same blank target attribute as a Warning in preview' {
|
||||
# In preview there is nothing to write, so the configuration is merely
|
||||
# pointless rather than dangerous.
|
||||
$findings = Get-SafetyFinding -Fixture 'PE-SAF-001-blank-target-production' -Code 'PE-SAF-001'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Warning'
|
||||
}
|
||||
|
||||
It 'detects an unsupported writable attribute' {
|
||||
# 'department' is authoritative in the sync source. Holding permission to
|
||||
# write it is not the same as owning it.
|
||||
$findings = Get-SafetyFinding -Fixture 'PE-SAF-002-unsupported-writable-attribute' -Code 'PE-SAF-002'
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
$findings[0].Description | Should -Match 'department'
|
||||
}
|
||||
|
||||
It 'detects enabled group rules while group retrieval is disabled' {
|
||||
$findings = Get-SafetyFinding -Fixture 'PE-SAF-003-group-rules-without-group-retrieval' -Code 'PE-SAF-003'
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
$findings[0].Location | Should -Be 'dataSources.groups.enabled'
|
||||
}
|
||||
|
||||
It 'detects a configuration version downgrade against the deployed baseline' {
|
||||
$findings = Get-SafetyFinding -Fixture 'PE-SAF-004-version-downgrade' -Code 'PE-SAF-004' `
|
||||
-PreviousConfigPath $script:baseline -EnforcementEnabled
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
$findings[0].Location | Should -Be 'configVersion'
|
||||
}
|
||||
|
||||
It 'detects a rule removed without a version change' {
|
||||
$findings = Get-SafetyFinding -Fixture 'PE-SAF-005-rule-removed-without-version-change' -Code 'PE-SAF-005' `
|
||||
-PreviousConfigPath $script:baseline -EnforcementEnabled
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
$findings[0].Description | Should -Match 'RULE-0900-EMPLOYEE'
|
||||
}
|
||||
|
||||
It 'accepts a rule removal when the version was raised to declare it' {
|
||||
$document = Get-Content -LiteralPath (Join-Path $script:corpus 'PE-SAF-005-rule-removed-without-version-change.json') -Raw | ConvertFrom-Json -Depth 32
|
||||
$document.configVersion = '1.1.0'
|
||||
|
||||
$findings = @(Test-PersonaConfigurationSafety -Document $document -PreviousConfigPath $script:baseline -EnforcementEnabled |
|
||||
Where-Object Code -EQ 'PE-SAF-005')
|
||||
|
||||
$findings.Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'detects condition tracing enabled without acknowledgement' {
|
||||
$findings = Get-SafetyFinding -Fixture 'PE-SAF-006-tracing-without-acknowledgement' -Code 'PE-SAF-006'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
$findings[0].Location | Should -Be 'logging.traceConditionValues'
|
||||
}
|
||||
|
||||
It 'accepts tracing when it is acknowledged in the same configuration' {
|
||||
$document = Get-Content -LiteralPath (Join-Path $script:corpus 'PE-SAF-006-tracing-without-acknowledgement.json') -Raw | ConvertFrom-Json -Depth 32
|
||||
$document.logging | Add-Member -NotePropertyName 'acknowledgeConditionTracing' -NotePropertyValue $true
|
||||
|
||||
@(Test-PersonaConfigurationSafety -Document $document | Where-Object Code -EQ 'PE-SAF-006') | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'detects a save that would overwrite an existing configuration with no backup' {
|
||||
$document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32
|
||||
|
||||
$findings = @(Test-PersonaConfigurationSafety -Document $document -SavePath $script:baseline |
|
||||
Where-Object Code -EQ 'PE-SAF-007')
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
}
|
||||
|
||||
It 'accepts the same save when a backup is planned' {
|
||||
$document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32
|
||||
|
||||
@(Test-PersonaConfigurationSafety -Document $document -SavePath $script:baseline -BackupPlanned |
|
||||
Where-Object Code -EQ 'PE-SAF-007') | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'A skipped check is reported as skipped, never as a pass' {
|
||||
|
||||
It 'reports an Information finding when no baseline is supplied' {
|
||||
# Silence would be read as approval. The comparison checks did not run, and
|
||||
# the output has to say so.
|
||||
$document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32
|
||||
|
||||
$findings = @(Test-PersonaConfigurationSafety -Document $document | Where-Object Code -EQ 'PE-SAF-004')
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Information'
|
||||
$findings[0].Description | Should -Match 'not a pass'
|
||||
}
|
||||
|
||||
It 'does not block on the skipped-check notice' {
|
||||
$document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32
|
||||
|
||||
@(Test-PersonaConfigurationSafety -Document $document | Where-Object Severity -EQ 'Error') | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Safety findings carry everything VR-004 requires' {
|
||||
|
||||
It 'gives every finding a severity, code, location, description, resolution, and layer' {
|
||||
foreach ($fixture in (Get-ChildItem -Path $script:corpus -Filter 'PE-SAF-*.json')) {
|
||||
$document = Get-Content -LiteralPath $fixture.FullName -Raw | ConvertFrom-Json -Depth 32
|
||||
|
||||
foreach ($finding in (Test-PersonaConfigurationSafety -Document $document -PreviousConfigPath $script:baseline)) {
|
||||
$finding.Severity | Should -BeIn @('Error', 'Warning', 'Information')
|
||||
$finding.Code | Should -Match '^PE-SAF-\d{3}$'
|
||||
$finding.Location | Should -Not -BeNullOrEmpty
|
||||
$finding.Description | Should -Not -BeNullOrEmpty
|
||||
$finding.SuggestedResolution | Should -Not -BeNullOrEmpty
|
||||
$finding.Layer | Should -Be 'Safety'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
SC-009: every VR-002 condition produces a finding with a code, a severity, and a
|
||||
location.
|
||||
|
||||
Layer 3 is exercised directly rather than through Test-PersonaConfiguration. Most
|
||||
of these defects are also caught by the JSON Schema, so a full-pipeline test would
|
||||
stop at layer 2 and never reach the code under test - it would be asserting that
|
||||
the schema works, which LayerOrdering.Tests.ps1 already does.
|
||||
|
||||
The overlap is deliberate (see the note in Test-PersonaConfigurationSemantic):
|
||||
layer 2 can be bypassed with -SchemaPath, and V-5a showed an unparseable schema
|
||||
passes silently on this build. Anything that can misclassify a privileged account
|
||||
is checked twice.
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
|
||||
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
|
||||
|
||||
$script:corpus = Join-Path $repoRoot 'tests/TestData/InvalidConfigs'
|
||||
|
||||
function Get-Finding {
|
||||
param([string] $Fixture, [string] $Code)
|
||||
|
||||
$document = Get-Content -LiteralPath (Join-Path $script:corpus "$Fixture.json") -Raw | ConvertFrom-Json -Depth 32
|
||||
@(Test-PersonaConfigurationSemantic -Document $document | Where-Object Code -EQ $Code)
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Semantic validation, VR-002 conditions (SC-009)' {
|
||||
|
||||
It 'detects duplicate rule IDs' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-001-duplicate-rule-id' -Code 'PE-SEM-001'
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
$findings[0].Location | Should -Match 'RULE-0010-GUEST'
|
||||
}
|
||||
|
||||
It 'detects duplicate priorities among enabled rules' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-002-duplicate-priority' -Code 'PE-SEM-002'
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
$findings[0].Description | Should -Match 'RULE-0010-GUEST'
|
||||
$findings[0].Description | Should -Match 'RULE-0900-EMPLOYEE'
|
||||
}
|
||||
|
||||
It 'detects a configuration with no enabled rules' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-003-no-enabled-rules' -Code 'PE-SEM-003'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
}
|
||||
|
||||
It 'detects a blank target attribute' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-004-blank-target-attribute' -Code 'PE-SEM-004'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Location | Should -Be 'engine.targetAttribute'
|
||||
}
|
||||
|
||||
It 'detects a target attribute absent from the approved list' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-005-target-not-approved' -Code 'PE-SEM-005'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
}
|
||||
|
||||
It 'detects a reference to a disabled data source' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-006-unavailable-data-source' -Code 'PE-SEM-006'
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
$findings[0].Description | Should -Match 'EvaluationError'
|
||||
}
|
||||
|
||||
It 'detects memberOf with no group Object IDs' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-007-memberof-without-groups' -Code 'PE-SEM-007'
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
}
|
||||
|
||||
It 'detects in without a values array' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-008-in-without-values' -Code 'PE-SEM-008'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
}
|
||||
|
||||
It 'detects isNotNull carrying a comparison value' {
|
||||
# The value is silently ignored at evaluation time, so the rule does not do
|
||||
# what the author plainly intended it to do.
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-009-isnull-with-value' -Code 'PE-SEM-009'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
}
|
||||
|
||||
It 'detects a persona absent from the declared catalogue' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-010-undeclared-persona' -Code 'PE-SEM-010'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Description | Should -Match 'Undeclared-Persona'
|
||||
}
|
||||
|
||||
It 'detects Unclassified used as an ordinary rule persona' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-011-unclassified-as-persona' -Code 'PE-SEM-011'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
}
|
||||
|
||||
It 'detects nesting deeper than the configured maximum' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-012-depth-over-configured-maximum' -Code 'PE-SEM-012'
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
$findings[0].Location | Should -Match 'conditions'
|
||||
}
|
||||
|
||||
It 'detects a configured maximum above the hard ceiling of 10' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-013-depth-over-hard-ceiling' -Code 'PE-SEM-013'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Location | Should -Be 'engine.maxConditionDepth'
|
||||
}
|
||||
|
||||
It 'warns when a condition mode differs from an explicitly pinned global mode' {
|
||||
# A Warning, not an Error: the three facets are retrieved independently, so
|
||||
# the condition is answered correctly. The cost is an extra call per account,
|
||||
# which is worth surfacing but not worth blocking.
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-014-mode-not-enabled-globally' -Code 'PE-SEM-014'
|
||||
|
||||
$findings.Count | Should -BeGreaterThan 0
|
||||
$findings[0].Severity | Should -Be 'Warning'
|
||||
}
|
||||
|
||||
It 'detects an unsupported property name' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-015-unsupported-property' -Code 'PE-SEM-015'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Description | Should -Match 'employeeHireDate'
|
||||
}
|
||||
|
||||
It 'detects an invalid regular expression' {
|
||||
$findings = Get-Finding -Fixture 'PE-SEM-016-invalid-regex' -Code 'PE-SEM-016'
|
||||
|
||||
$findings.Count | Should -Be 1
|
||||
$findings[0].Severity | Should -Be 'Error'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Semantic findings carry everything VR-004 requires' {
|
||||
|
||||
It 'gives every finding a severity, code, location, description, resolution, and layer' {
|
||||
$fixtures = Get-ChildItem -Path $script:corpus -Filter 'PE-SEM-*.json'
|
||||
$fixtures.Count | Should -BeGreaterThan 0
|
||||
|
||||
foreach ($fixture in $fixtures) {
|
||||
$document = Get-Content -LiteralPath $fixture.FullName -Raw | ConvertFrom-Json -Depth 32
|
||||
|
||||
foreach ($finding in (Test-PersonaConfigurationSemantic -Document $document)) {
|
||||
$finding.Severity | Should -BeIn @('Error', 'Warning', 'Information')
|
||||
$finding.Code | Should -Match '^PE-SEM-\d{3}$'
|
||||
$finding.Location | Should -Not -BeNullOrEmpty
|
||||
$finding.Description | Should -Not -BeNullOrEmpty
|
||||
$finding.SuggestedResolution | Should -Not -BeNullOrEmpty
|
||||
$finding.Layer | Should -Be 'Semantic'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It 'produces no findings for the valid baseline' {
|
||||
# Without this, a validator that flagged everything would pass every test above.
|
||||
$document = Get-Content -LiteralPath (Join-Path $script:corpus 'baseline-deployed.json') -Raw | ConvertFrom-Json -Depth 32
|
||||
|
||||
@(Test-PersonaConfigurationSemantic -Document $document) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'produces no findings for the shipped example configuration' {
|
||||
$document = Get-Content -LiteralPath (Join-Path $repoRoot 'config/persona-engine.example.json') -Raw | ConvertFrom-Json -Depth 32
|
||||
|
||||
@(Test-PersonaConfigurationSemantic -Document $document) | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
Pins the V-5a observation about Test-Json -SchemaFile.
|
||||
|
||||
OTD-005 chose a cmdlet whose failure reporting is version-dependent, and
|
||||
validation layer 2 is written against the behaviour observed on PowerShell 7.6.5
|
||||
(specs/001-persona-engine/verification/V-5a.md). If a future build changes any row
|
||||
of that table, the wrapper's assumptions change with it - and the failure mode is
|
||||
silent: configurations start passing validation that should not.
|
||||
|
||||
These tests fail loudly at that moment instead.
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
|
||||
$script:schemaFile = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-pin-{0}.json" -f [guid]::NewGuid().ToString('N'))
|
||||
Set-Content -LiteralPath $script:schemaFile -Encoding utf8NoBOM -Value @'
|
||||
{ "type": "object", "required": ["a"], "properties": { "a": { "type": "string" } } }
|
||||
'@
|
||||
|
||||
$script:brokenSchemaFile = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-pin-broken-{0}.json" -f [guid]::NewGuid().ToString('N'))
|
||||
Set-Content -LiteralPath $script:brokenSchemaFile -Encoding utf8NoBOM -Value '{ not json'
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Remove-Item -LiteralPath $script:schemaFile, $script:brokenSchemaFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Describe 'Test-Json -SchemaFile failure behaviour (V-5a)' {
|
||||
|
||||
It 'returns $true and writes nothing for a valid document' {
|
||||
$errors = $null
|
||||
$result = '{ "a": "ok" }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
|
||||
|
||||
$result | Should -BeTrue
|
||||
@($errors).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'returns $false and writes one error for a type mismatch' {
|
||||
$errors = $null
|
||||
$result = '{ "a": 123 }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
|
||||
|
||||
$result | Should -BeFalse
|
||||
@($errors).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'returns $false and writes one error for a missing required property' {
|
||||
$errors = $null
|
||||
$result = '{}' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
|
||||
|
||||
$result | Should -BeFalse
|
||||
@($errors).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'reports failure without throwing, so -ErrorVariable is sufficient' {
|
||||
# If a future build made this terminating, layer 2 would abort the run instead
|
||||
# of returning findings, and the editor would report an exception rather than
|
||||
# a PE-SCH finding.
|
||||
{ '{ "a": 123 }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It 'returns $TRUE when the schema itself cannot be parsed - the trap layer 2 is built around' {
|
||||
# The load-bearing observation. A wrapper trusting the return value alone
|
||||
# would report every configuration as schema-valid against a schema that
|
||||
# never ran.
|
||||
$errors = $null
|
||||
$result = '{}' | Test-Json -SchemaFile $script:brokenSchemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
|
||||
|
||||
$result | Should -BeTrue
|
||||
@($errors).Count | Should -Be 1
|
||||
$errors[0].Exception.Message | Should -Match 'Cannot parse the JSON schema'
|
||||
}
|
||||
|
||||
It 'embeds a JSON pointer in the failure message, which VR-004 needs for Location' {
|
||||
$errors = $null
|
||||
$null = '{ "a": 123 }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
|
||||
|
||||
$errors[0].Exception.Message | Should -Match "at '/a'"
|
||||
}
|
||||
|
||||
It 'reports one error per violating location when several properties fail' {
|
||||
# Not exhaustive across nested subschemas, but not first-failure-only either.
|
||||
# Layer 2 therefore emits one finding per collected error rather than assuming
|
||||
# a single one.
|
||||
$multiSchema = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-pin-multi-{0}.json" -f [guid]::NewGuid().ToString('N'))
|
||||
Set-Content -LiteralPath $multiSchema -Encoding utf8NoBOM -Value @'
|
||||
{ "type": "object", "required": ["a", "b"], "properties": { "a": { "type": "string" }, "b": { "type": "string" } } }
|
||||
'@
|
||||
try {
|
||||
$errors = $null
|
||||
$null = '{ "a": 1, "b": 2 }' | Test-Json -SchemaFile $multiSchema -ErrorAction SilentlyContinue -ErrorVariable errors
|
||||
|
||||
@($errors).Count | Should -Be 2
|
||||
($errors | ForEach-Object { $_.Exception.Message }) -join ' ' | Should -Match "at '/a'"
|
||||
($errors | ForEach-Object { $_.Exception.Message }) -join ' ' | Should -Match "at '/b'"
|
||||
}
|
||||
finally { Remove-Item -LiteralPath $multiSchema -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
VR-003: traceConditionValues without explicit acknowledgement is a safety finding.
|
||||
|
||||
The acknowledgement lives in the configuration rather than in a command-line
|
||||
switch, and that placement is the point. A flag passed at the console is invisible
|
||||
to review; a field in the configuration appears in the diff of the change that
|
||||
enables tracing, next to the person who approved it.
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
|
||||
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
|
||||
|
||||
$script:schema = Join-Path $repoRoot 'config/persona-engine.schema.json'
|
||||
$script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-trace-{0}" -f [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory -Path $script:scratch -Force
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Describe 'Tracing acknowledgement (VR-003)' {
|
||||
|
||||
It 'produces PE-SAF-006 when tracing is on and acknowledgement is absent' {
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.logging = @{ destination = 'stream'; traceConditionValues = $true }
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
$result.IsValid | Should -BeFalse
|
||||
$result.Findings.Code | Should -Contain 'PE-SAF-006'
|
||||
}
|
||||
|
||||
It 'produces PE-SAF-006 when acknowledgement is present but false' {
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.logging = @{ destination = 'stream'; traceConditionValues = $true; acknowledgeConditionTracing = $false }
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
$result.Findings.Code | Should -Contain 'PE-SAF-006'
|
||||
}
|
||||
|
||||
It 'accepts tracing when acknowledgement is true' {
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.logging = @{ destination = 'stream'; traceConditionValues = $true; acknowledgeConditionTracing = $true }
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
$result.IsValid | Should -BeTrue
|
||||
$result.Findings.Code | Should -Not -Contain 'PE-SAF-006'
|
||||
}
|
||||
|
||||
It 'does not require acknowledgement when tracing is off' {
|
||||
# Acknowledging something that is not happening would train people to set the
|
||||
# field reflexively, which is how an acknowledgement stops meaning anything.
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.logging = @{ destination = 'stream'; traceConditionValues = $false }
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
$result.IsValid | Should -BeTrue
|
||||
$result.Findings.Code | Should -Not -Contain 'PE-SAF-006'
|
||||
}
|
||||
|
||||
It 'blocks the run: the finding is an Error, not a Warning' {
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.logging = @{ destination = 'stream'; traceConditionValues = $true }
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
($result.Findings | Where-Object Code -EQ 'PE-SAF-006').Severity | Should -Be 'Error'
|
||||
}
|
||||
|
||||
It 'explains what tracing actually widens, not merely that it is enabled' {
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.logging = @{ destination = 'stream'; traceConditionValues = $true }
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
$finding = (Test-PersonaConfiguration -Path $path -SchemaPath $script:schema).Findings |
|
||||
Where-Object Code -EQ 'PE-SAF-006'
|
||||
|
||||
$finding.Description | Should -Match 'attribute values'
|
||||
$finding.SuggestedResolution | Should -Match 'acknowledgeConditionTracing'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'The schema accepts the acknowledgement field' {
|
||||
|
||||
It 'validates a configuration carrying acknowledgeConditionTracing' {
|
||||
# additionalProperties is false on the logging block, so the field has to be
|
||||
# declared in the schema or the acknowledgement itself becomes a schema error.
|
||||
$document = New-TestConfigurationDocument
|
||||
$document.logging = @{ destination = 'both'; path = '<LOG-OUTPUT-PATH>'; traceConditionValues = $true; acknowledgeConditionTracing = $true }
|
||||
|
||||
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
|
||||
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
|
||||
|
||||
$result.Findings.Code | Should -Not -Contain 'PE-SCH-001'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<#
|
||||
Shared Pester configuration.
|
||||
|
||||
Three suites, separated by tag so the offline set can be run with no tenant,
|
||||
no credentials, and no network (SC-008):
|
||||
|
||||
Offline - rule engine, normalization, configuration validation
|
||||
Safety - zero-write and single-attribute-body assertions (mocked adapter)
|
||||
Integration - requires a delegated read-only tenant connection (Stage A2)
|
||||
|
||||
Usage:
|
||||
$cfg = & ./tests/PesterConfiguration.ps1 -Suite Offline
|
||||
Invoke-Pester -Configuration $cfg
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Offline', 'Safety', 'Integration', 'All')]
|
||||
[string] $Suite = 'Offline',
|
||||
|
||||
[string] $Path = (Join-Path $PSScriptRoot '.')
|
||||
)
|
||||
|
||||
$config = New-PesterConfiguration
|
||||
$config.Run.Path = $Path
|
||||
$config.Output.Verbosity = 'Detailed'
|
||||
$config.Should.ErrorAction = 'Continue'
|
||||
|
||||
switch ($Suite) {
|
||||
'Offline' {
|
||||
# Integration is excluded rather than Offline included, so a new suite
|
||||
# that forgets its tag still runs offline instead of silently never running.
|
||||
$config.Filter.ExcludeTag = @('Integration')
|
||||
}
|
||||
'Safety' { $config.Filter.Tag = @('Safety') }
|
||||
'Integration' { $config.Filter.Tag = @('Integration') }
|
||||
'All' { }
|
||||
}
|
||||
|
||||
$config
|
||||
@@ -0,0 +1,107 @@
|
||||
#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'
|
||||
)) { . (Join-Path $repoRoot $f) }
|
||||
|
||||
$script:user = New-PersonaUserRecord `
|
||||
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
|
||||
-UserPrincipalName 'svc-billing@example.invalid' `
|
||||
-UserType 'Member' `
|
||||
-Properties @{ Department = 'Finance'; JobTitle = 'Analyst' }
|
||||
|
||||
function Cond {
|
||||
param([string] $Property, [string] $Operator = 'equals', [string] $Value)
|
||||
[pscustomobject]@{ type = 'property'; property = $Property; operator = $Operator; value = $Value }
|
||||
}
|
||||
|
||||
function New-ConditionGroup {
|
||||
param([string] $Operator, [object[]] $Conditions)
|
||||
[pscustomobject]@{ operator = $Operator; conditions = $Conditions }
|
||||
}
|
||||
|
||||
# Builds exactly $Depth nested groups around a leaf condition that is always
|
||||
# true. Leaf conditions do not add a level: the engine counts group nesting,
|
||||
# so -Depth 3 yields group(group(group(leaf))) and evaluates at depths 1..3.
|
||||
function New-NestedGroup {
|
||||
param([int] $Depth)
|
||||
$node = Cond -Property 'Department' -Value 'Finance'
|
||||
for ($i = 0; $i -lt $Depth; $i++) {
|
||||
$node = New-ConditionGroup -Operator 'all' -Conditions @($node)
|
||||
}
|
||||
$node
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Logical composition (RE-003)' {
|
||||
|
||||
Context 'all' {
|
||||
It 'is true when every condition is true' {
|
||||
Test-PersonaConditionGroup -Group (New-ConditionGroup 'all' @((Cond 'Department' -Value 'Finance'), (Cond 'JobTitle' -Value 'Analyst'))) -UserRecord $user | Should -Be 'True'
|
||||
}
|
||||
It 'is false when any condition is false' {
|
||||
Test-PersonaConditionGroup -Group (New-ConditionGroup 'all' @((Cond 'Department' -Value 'Finance'), (Cond 'JobTitle' -Value 'Manager'))) -UserRecord $user | Should -Be 'False'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'any' {
|
||||
It 'is true when at least one condition is true' {
|
||||
Test-PersonaConditionGroup -Group (New-ConditionGroup 'any' @((Cond 'Department' -Value 'Legal'), (Cond 'JobTitle' -Value 'Analyst'))) -UserRecord $user | Should -Be 'True'
|
||||
}
|
||||
It 'is false when every condition is false' {
|
||||
Test-PersonaConditionGroup -Group (New-ConditionGroup 'any' @((Cond 'Department' -Value 'Legal'), (Cond 'JobTitle' -Value 'Manager'))) -UserRecord $user | Should -Be 'False'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'nesting' {
|
||||
It 'resolves an any group nested inside an all group' {
|
||||
$inner = New-ConditionGroup 'any' @((Cond 'JobTitle' -Value 'Manager'), (Cond 'JobTitle' -Value 'Analyst'))
|
||||
$outer = New-ConditionGroup 'all' @((Cond 'Department' -Value 'Finance'), $inner)
|
||||
Test-PersonaConditionGroup -Group $outer -UserRecord $user | Should -Be 'True'
|
||||
}
|
||||
|
||||
It 'resolves an all group nested inside an any group' {
|
||||
$inner = New-ConditionGroup 'all' @((Cond 'Department' -Value 'Legal'), (Cond 'JobTitle' -Value 'Analyst'))
|
||||
$outer = New-ConditionGroup 'any' @((Cond 'Department' -Value 'Finance'), $inner)
|
||||
Test-PersonaConditionGroup -Group $outer -UserRecord $user | Should -Be 'True'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'empty and malformed groups' {
|
||||
It 'returns Unknown for an empty conditions collection' {
|
||||
Test-PersonaConditionGroup -Group (New-ConditionGroup 'all' @()) -UserRecord $user | Should -Be 'Unknown'
|
||||
}
|
||||
It 'returns Unknown for an unrecognized group operator' {
|
||||
Test-PersonaConditionGroup -Group (New-ConditionGroup 'either' @((Cond 'Department' -Value 'Finance'))) -UserRecord $user | Should -Be 'Unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Depth limits (RE-004)' {
|
||||
|
||||
It 'evaluates a tree exactly at the configured depth' {
|
||||
Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 5) -UserRecord $user -MaxDepth 5 | Should -Be 'True'
|
||||
}
|
||||
|
||||
It 'returns Unknown beyond the configured depth rather than truncating' {
|
||||
# Silent truncation would evaluate a rule the author did not write.
|
||||
Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 7) -UserRecord $user -MaxDepth 5 | Should -Be 'Unknown'
|
||||
}
|
||||
|
||||
It 'honours a lowered depth limit' {
|
||||
Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 3) -UserRecord $user -MaxDepth 2 | Should -Be 'Unknown'
|
||||
}
|
||||
|
||||
It 'rejects a MaxDepth above the hard ceiling of 10' {
|
||||
{ Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 2) -UserRecord $user -MaxDepth 11 } | Should -Throw
|
||||
}
|
||||
|
||||
It 'rejects a MaxDepth below the minimum of 1' {
|
||||
{ Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 2) -UserRecord $user -MaxDepth 0 } | Should -Throw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#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:fixtures = (Get-Content (Join-Path $repoRoot 'tests/TestData/Users/users.json') -Raw | ConvertFrom-Json).users
|
||||
|
||||
$script:users = foreach ($f in $fixtures) {
|
||||
$props = @{}
|
||||
foreach ($p in $f.properties.PSObject.Properties) { $props[$p.Name] = $p.Value }
|
||||
|
||||
New-PersonaUserRecord `
|
||||
-AccountObjectId $f.accountObjectId `
|
||||
-UserPrincipalName $f.userPrincipalName `
|
||||
-DisplayName $f.displayName `
|
||||
-UserType $f.userType `
|
||||
-AccountEnabled $f.accountEnabled `
|
||||
-Properties $props `
|
||||
-StoredPersona $f.storedPersona
|
||||
}
|
||||
|
||||
$script:rules = @(
|
||||
[pscustomobject]@{
|
||||
id = 'R-020'; priority = 20; persona = 'Guest'; enabled = $true
|
||||
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }) }
|
||||
}
|
||||
[pscustomobject]@{
|
||||
id = 'R-040'; priority = 40; persona = 'Service-Account'; enabled = $true
|
||||
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'UserPrincipalName'; operator = 'startsWith'; value = 'svc-' }) }
|
||||
}
|
||||
[pscustomobject]@{
|
||||
id = 'R-900'; priority = 900; persona = 'Employee'; enabled = $true
|
||||
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) }
|
||||
}
|
||||
)
|
||||
|
||||
function Get-DecisionSignature {
|
||||
param([object[]] $Users, [object[]] $RuleSet)
|
||||
# Deliberately excludes DurationMs: timing is telemetry, not part of the
|
||||
# decision, and including it would make this test measure the clock.
|
||||
($Users | ForEach-Object {
|
||||
$r = Resolve-UserPersona -UserRecord $_ -Rules $RuleSet
|
||||
'{0}|{1}|{2}|{3}' -f $r.AccountObjectId, $r.Outcome, $r.CalculatedPersona, $r.MatchedRuleId
|
||||
} | Sort-Object) -join "`n"
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Determinism (SC-003)' {
|
||||
|
||||
It 'produces identical results across repeated runs' {
|
||||
$first = Get-DecisionSignature -Users $users -RuleSet $rules
|
||||
$second = Get-DecisionSignature -Users $users -RuleSet $rules
|
||||
$second | Should -Be $first
|
||||
}
|
||||
|
||||
It 'produces identical results when the user collection is shuffled' {
|
||||
$ordered = Get-DecisionSignature -Users $users -RuleSet $rules
|
||||
|
||||
foreach ($seed in 1..5) {
|
||||
$shuffled = $users | Sort-Object { ($_.AccountObjectId + $seed).GetHashCode() }
|
||||
Get-DecisionSignature -Users $shuffled -RuleSet $rules | Should -Be $ordered
|
||||
}
|
||||
}
|
||||
|
||||
It 'produces identical results when the rule collection is shuffled' {
|
||||
$ordered = Get-DecisionSignature -Users $users -RuleSet $rules
|
||||
|
||||
foreach ($seed in 1..5) {
|
||||
$shuffled = $rules | Sort-Object { ($_.id + $seed).GetHashCode() }
|
||||
Get-DecisionSignature -Users $users -RuleSet $shuffled | Should -Be $ordered
|
||||
}
|
||||
}
|
||||
|
||||
It 'assigns exactly one outcome to every fixture (SC-001)' {
|
||||
foreach ($u in $users) {
|
||||
$r = Resolve-UserPersona -UserRecord $u -Rules $rules
|
||||
$r.Outcome | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Offline execution (SC-008)' {
|
||||
|
||||
It 'evaluates every fixture without any network-capable command in the engine' {
|
||||
# The structural guarantee is enforced by tests/Test-EnginePurity.ps1. This
|
||||
# asserts the practical consequence: the engine runs with nothing loaded but
|
||||
# its own files.
|
||||
$results = $users | ForEach-Object { Resolve-UserPersona -UserRecord $_ -Rules $rules }
|
||||
$results | Should -HaveCount $users.Count
|
||||
}
|
||||
|
||||
It 'has not loaded the Graph authentication module' {
|
||||
(Get-Module -Name 'Microsoft.Graph.Authentication') | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
#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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
#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/RuleEngine/Test-PersonaCondition.ps1')
|
||||
|
||||
function New-TestUser {
|
||||
param([hashtable] $Properties = @{}, [object] $Membership)
|
||||
New-PersonaUserRecord `
|
||||
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
|
||||
-UserPrincipalName 'alex.employee@example.invalid' `
|
||||
-UserType 'Member' `
|
||||
-Properties $Properties `
|
||||
-Membership $Membership
|
||||
}
|
||||
|
||||
function Test-Op {
|
||||
param([hashtable] $Condition, [object] $User, [string] $Mode = 'Direct')
|
||||
Test-PersonaCondition -Condition ([pscustomobject]$Condition) -UserRecord $User -DefaultMembershipMode $Mode
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Property operators (RE-005)' {
|
||||
|
||||
BeforeAll {
|
||||
$script:user = New-TestUser -Properties @{
|
||||
Department = 'Finance'
|
||||
JobTitle = 'Senior Analyst'
|
||||
CompanyName = $null
|
||||
EmptyString = ''
|
||||
}
|
||||
}
|
||||
|
||||
Context 'equals / notEquals' {
|
||||
It 'equals matches' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Finance' } $user | Should -Be 'True' }
|
||||
It 'equals rejects a different value' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Legal' } $user | Should -Be 'False' }
|
||||
It 'notEquals is the inverse' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'notEquals'; value = 'Legal' } $user | Should -Be 'True' }
|
||||
}
|
||||
|
||||
Context 'case insensitivity (RE-006)' {
|
||||
It 'equals ignores case' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'FINANCE' } $user | Should -Be 'True' }
|
||||
It 'contains ignores case' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'contains'; value = 'ANALYST' } $user | Should -Be 'True' }
|
||||
It 'startsWith ignores case' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'startsWith'; value = 'senior' } $user | Should -Be 'True' }
|
||||
It 'endsWith ignores case' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'endsWith'; value = 'ANALYST' } $user | Should -Be 'True' }
|
||||
It 'resolves the property name case-insensitively' { Test-Op @{ type = 'property'; property = 'DEPARTMENT'; operator = 'equals'; value = 'Finance' } $user | Should -Be 'True' }
|
||||
}
|
||||
|
||||
Context 'contains / notContains' {
|
||||
It 'contains matches a substring' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'contains'; value = 'Analy' } $user | Should -Be 'True' }
|
||||
It 'notContains is the inverse' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'notContains'; value = 'Manager' } $user | Should -Be 'True' }
|
||||
}
|
||||
|
||||
Context 'startsWith / endsWith' {
|
||||
It 'startsWith matches' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'startsWith'; value = 'Senior' } $user | Should -Be 'True' }
|
||||
It 'startsWith rejects' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'startsWith'; value = 'Junior' } $user | Should -Be 'False' }
|
||||
It 'endsWith matches' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'endsWith'; value = 'Analyst' } $user | Should -Be 'True' }
|
||||
}
|
||||
|
||||
Context 'matchesRegex' {
|
||||
It 'matches a valid pattern' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'matchesRegex'; value = '^Senior\s+\w+$' } $user | Should -Be 'True' }
|
||||
It 'rejects a non-matching pattern' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'matchesRegex'; value = '^Junior' } $user | Should -Be 'False' }
|
||||
|
||||
It 'returns Unknown for an invalid pattern rather than a false non-match' {
|
||||
# An unparseable regex is a configuration defect. Reporting False would
|
||||
# hide it behind a plausible result.
|
||||
Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'matchesRegex'; value = '[unclosed' } $user | Should -Be 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'in / notIn' {
|
||||
It 'in matches a listed value' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'in'; values = @('Legal', 'Finance') } $user | Should -Be 'True' }
|
||||
It 'in rejects an unlisted value' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'in'; values = @('Legal', 'HR') } $user | Should -Be 'False' }
|
||||
It 'notIn is the inverse' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'notIn'; values = @('Legal', 'HR') } $user | Should -Be 'True' }
|
||||
It 'in ignores case' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'in'; values = @('FINANCE') } $user | Should -Be 'True' }
|
||||
}
|
||||
|
||||
Context 'isNull / isNotNull' {
|
||||
It 'isNull is true for an explicit null' { Test-Op @{ type = 'property'; property = 'CompanyName'; operator = 'isNull' } $user | Should -Be 'True' }
|
||||
It 'isNull is true for an absent property' { Test-Op @{ type = 'property'; property = 'NoSuchProperty'; operator = 'isNull' } $user | Should -Be 'True' }
|
||||
It 'isNull is true for an empty string' { Test-Op @{ type = 'property'; property = 'EmptyString'; operator = 'isNull' } $user | Should -Be 'True' }
|
||||
It 'isNull is false for a populated value' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'isNull' } $user | Should -Be 'False' }
|
||||
It 'isNotNull is the inverse' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'isNotNull' } $user | Should -Be 'True' }
|
||||
}
|
||||
|
||||
Context 'null handling for ordinary comparisons (FR-012)' {
|
||||
|
||||
It 'treats a null property as empty rather than failing' {
|
||||
Test-Op @{ type = 'property'; property = 'CompanyName'; operator = 'equals'; value = '' } $user | Should -Be 'True'
|
||||
}
|
||||
|
||||
It 'treats an absent property as empty rather than failing' {
|
||||
Test-Op @{ type = 'property'; property = 'NoSuchProperty'; operator = 'equals'; value = 'anything' } $user | Should -Be 'False'
|
||||
}
|
||||
|
||||
It 'never returns Unknown for a null property under an ordinary operator' {
|
||||
# FR-012: null must not cause evaluation failure. Unknown here would
|
||||
# turn every sparse account into an EvaluationError.
|
||||
foreach ($op in @('equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'endsWith')) {
|
||||
Test-Op @{ type = 'property'; property = 'CompanyName'; operator = $op; value = 'x' } $user |
|
||||
Should -Not -Be 'Unknown' -Because "operator '$op' must tolerate a null property"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Context 'unsupported operator' {
|
||||
It 'returns Unknown rather than guessing' {
|
||||
Test-Op @{ type = 'property'; property = 'Department'; operator = 'approximatelyEquals'; value = 'Finance' } $user | Should -Be 'Unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Membership operators' {
|
||||
|
||||
BeforeAll {
|
||||
$script:tier0 = '00000000-0000-0000-0000-0000000000a0'
|
||||
$script:other = '00000000-0000-0000-0000-0000000000b0'
|
||||
}
|
||||
|
||||
Context 'successful retrieval' {
|
||||
|
||||
BeforeAll {
|
||||
$script:member = New-TestUser -Membership (New-PersonaMembershipRecord -DirectGroupObjectIds @($tier0) -DirectRetrieved)
|
||||
}
|
||||
|
||||
It 'memberOf matches a held group' { Test-Op @{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0) } $member | Should -Be 'True' }
|
||||
It 'memberOf rejects a group not held' { Test-Op @{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($other) } $member | Should -Be 'False' }
|
||||
It 'notMemberOf is the inverse' { Test-Op @{ type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($other) } $member | Should -Be 'True' }
|
||||
It 'matches when any listed group is held' { Test-Op @{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($other, $tier0) } $member | Should -Be 'True' }
|
||||
}
|
||||
|
||||
Context 'membership mode (RE-007)' {
|
||||
|
||||
It 'answers a direct question from direct data' {
|
||||
$u = New-TestUser -Membership (New-PersonaMembershipRecord -DirectGroupObjectIds @($tier0) -DirectRetrieved)
|
||||
Test-Op @{ type = 'membership'; operator = 'memberOf'; membershipMode = 'Direct'; groupObjectIds = @($tier0) } $u | Should -Be 'True'
|
||||
}
|
||||
|
||||
It 'returns Unknown when asked a transitive question with only direct data' {
|
||||
# Transitive is a superset of direct, so answering from direct data
|
||||
# would produce false negatives on nested groups.
|
||||
$u = New-TestUser -Membership (New-PersonaMembershipRecord -DirectGroupObjectIds @($tier0) -DirectRetrieved)
|
||||
Test-Op @{ type = 'membership'; operator = 'memberOf'; membershipMode = 'Transitive'; groupObjectIds = @($tier0) } $u | Should -Be 'Unknown'
|
||||
}
|
||||
|
||||
It 'returns Unknown when asked a direct question with only transitive data' {
|
||||
$u = New-TestUser -Membership (New-PersonaMembershipRecord -TransitiveGroupObjectIds @($tier0) -TransitiveRetrieved)
|
||||
Test-Op @{ type = 'membership'; operator = 'memberOf'; membershipMode = 'Direct'; groupObjectIds = @($tier0) } $u | Should -Be 'Unknown'
|
||||
}
|
||||
|
||||
It 'falls back to the engine default mode when the condition omits one' {
|
||||
$u = New-TestUser -Membership (New-PersonaMembershipRecord -TransitiveGroupObjectIds @($tier0) -TransitiveRetrieved)
|
||||
Test-Op @{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0) } $u 'Transitive' | Should -Be 'True'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'role conditions' {
|
||||
|
||||
It 'matches a held directory role' {
|
||||
$u = New-TestUser -Membership (New-PersonaMembershipRecord -DirectoryRoleIds @('<TIER0-ROLE-TEMPLATE-ID>') -RolesRetrieved)
|
||||
Test-Op @{ type = 'role'; operator = 'memberOf'; roleIds = @('<TIER0-ROLE-TEMPLATE-ID>') } $u | Should -Be 'True'
|
||||
}
|
||||
|
||||
It 'is not affected by membership mode' {
|
||||
# Role assignments have no direct/transitive distinction in this model.
|
||||
$u = New-TestUser -Membership (New-PersonaMembershipRecord -DirectoryRoleIds @('<TIER0-ROLE-TEMPLATE-ID>') -RolesRetrieved)
|
||||
Test-Op @{ type = 'role'; operator = 'memberOf'; roleIds = @('<TIER0-ROLE-TEMPLATE-ID>') } $u 'Direct' | Should -Be 'True'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#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) }
|
||||
|
||||
function New-MatchAllRule {
|
||||
param([string] $Id, [int] $Priority, [string] $Persona, [bool] $Enabled = $true, [string] $Department = 'Finance')
|
||||
[pscustomobject]@{
|
||||
id = $Id
|
||||
priority = $Priority
|
||||
persona = $Persona
|
||||
enabled = $Enabled
|
||||
match = [pscustomobject]@{
|
||||
operator = 'all'
|
||||
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = $Department })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$script:user = New-PersonaUserRecord `
|
||||
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
|
||||
-UserPrincipalName 'alex.employee@example.invalid' `
|
||||
-Properties @{ Department = 'Finance' }
|
||||
}
|
||||
|
||||
Describe 'Rule ordering and first-match (FR-008, FR-009, RE-002)' {
|
||||
|
||||
It 'evaluates in ascending priority order and stops at the first match' {
|
||||
$rules = @(
|
||||
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
|
||||
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin'
|
||||
)
|
||||
$result = Resolve-UserPersona -UserRecord $user -Rules $rules
|
||||
|
||||
$result.CalculatedPersona | Should -Be 'Tier0-Admin'
|
||||
$result.MatchedRuleId | Should -Be 'R-010'
|
||||
}
|
||||
|
||||
It 'stops evaluating once matched, leaving lower-priority rules unevaluated' {
|
||||
$rules = @(
|
||||
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin'
|
||||
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
|
||||
New-MatchAllRule -Id 'R-030' -Priority 30 -Persona 'Employee'
|
||||
)
|
||||
(Resolve-UserPersona -UserRecord $user -Rules $rules).RulesEvaluated | Should -Be 1
|
||||
}
|
||||
|
||||
It 'is unaffected by the order rules appear in the collection' {
|
||||
$ascending = @(
|
||||
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin'
|
||||
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
|
||||
)
|
||||
$descending = @(
|
||||
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
|
||||
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin'
|
||||
)
|
||||
|
||||
(Resolve-UserPersona -UserRecord $user -Rules $ascending).CalculatedPersona |
|
||||
Should -Be (Resolve-UserPersona -UserRecord $user -Rules $descending).CalculatedPersona
|
||||
}
|
||||
|
||||
It 'skips disabled rules and excludes them from the evaluated count' {
|
||||
$rules = @(
|
||||
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin' -Enabled $false
|
||||
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
|
||||
)
|
||||
$result = Resolve-UserPersona -UserRecord $user -Rules $rules
|
||||
|
||||
$result.CalculatedPersona | Should -Be 'Tier1-Admin'
|
||||
$result.RulesEvaluated | Should -Be 1
|
||||
}
|
||||
|
||||
It 'breaks a duplicate-priority tie deterministically by rule id' {
|
||||
# Duplicate priorities are a validation error (VR-002). If one reaches the
|
||||
# engine anyway, the result must still not depend on collection order.
|
||||
$a = @(
|
||||
New-MatchAllRule -Id 'R-AAA' -Priority 10 -Persona 'Persona-A'
|
||||
New-MatchAllRule -Id 'R-BBB' -Priority 10 -Persona 'Persona-B'
|
||||
)
|
||||
$b = @(
|
||||
New-MatchAllRule -Id 'R-BBB' -Priority 10 -Persona 'Persona-B'
|
||||
New-MatchAllRule -Id 'R-AAA' -Priority 10 -Persona 'Persona-A'
|
||||
)
|
||||
|
||||
(Resolve-UserPersona -UserRecord $user -Rules $a).CalculatedPersona | Should -Be 'Persona-A'
|
||||
(Resolve-UserPersona -UserRecord $user -Rules $b).CalculatedPersona | Should -Be 'Persona-A'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Outcome exclusivity (SC-001)' {
|
||||
|
||||
It 'returns exactly one outcome for every user' {
|
||||
$rules = @(New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Employee')
|
||||
$result = Resolve-UserPersona -UserRecord $user -Rules $rules
|
||||
|
||||
$result.Outcome | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError')
|
||||
}
|
||||
|
||||
It 'populates MatchedRuleId only when matched' {
|
||||
$matched = Resolve-UserPersona -UserRecord $user -Rules @(New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Employee')
|
||||
$matched.MatchedRuleId | Should -Be 'R-010'
|
||||
|
||||
$unmatched = Resolve-UserPersona -UserRecord $user -Rules @(New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Employee' -Department 'Legal')
|
||||
$unmatched.MatchedRuleId | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#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:user = New-PersonaUserRecord `
|
||||
-AccountObjectId '00000000-0000-0000-0000-000000000105' `
|
||||
-UserPrincipalName 'ellis.minimal@example.invalid' `
|
||||
-UserType 'Member' `
|
||||
-StoredPersona 'Employee'
|
||||
|
||||
function New-NonMatchingRule {
|
||||
param([string] $Id, [int] $Priority)
|
||||
[pscustomobject]@{
|
||||
id = $Id; priority = $Priority; persona = 'Tier0-Admin'; enabled = $true
|
||||
match = [pscustomobject]@{
|
||||
operator = 'all'
|
||||
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'NoSuchDepartment' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Unclassified outcome (FR-010)' {
|
||||
|
||||
It 'is the result when every enabled rule evaluates successfully and none match' {
|
||||
$result = Resolve-UserPersona -UserRecord $user -Rules @(
|
||||
New-NonMatchingRule -Id 'R-010' -Priority 10
|
||||
New-NonMatchingRule -Id 'R-020' -Priority 20
|
||||
)
|
||||
|
||||
$result.Outcome | Should -Be 'Unclassified'
|
||||
$result.CalculatedPersona | Should -Be 'Unclassified'
|
||||
$result.MatchedRuleId | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'evaluates every enabled rule before concluding' {
|
||||
$result = Resolve-UserPersona -UserRecord $user -Rules @(
|
||||
New-NonMatchingRule -Id 'R-010' -Priority 10
|
||||
New-NonMatchingRule -Id 'R-020' -Priority 20
|
||||
New-NonMatchingRule -Id 'R-030' -Priority 30
|
||||
)
|
||||
$result.RulesEvaluated | Should -Be 3
|
||||
}
|
||||
|
||||
It 'is the result for an empty rule set' {
|
||||
$result = Resolve-UserPersona -UserRecord $user -Rules @()
|
||||
$result.Outcome | Should -Be 'Unclassified'
|
||||
}
|
||||
|
||||
It 'is the result when every rule is disabled' {
|
||||
$disabled = New-NonMatchingRule -Id 'R-010' -Priority 10
|
||||
$disabled.enabled = $false
|
||||
|
||||
$result = Resolve-UserPersona -UserRecord $user -Rules @($disabled)
|
||||
$result.Outcome | Should -Be 'Unclassified'
|
||||
$result.RulesEvaluated | Should -Be 0
|
||||
}
|
||||
|
||||
It 'is reported distinctly from EvaluationError' {
|
||||
# Both mean "no persona was assigned", but only one means the engine failed.
|
||||
# Conflating them would hide data-availability problems inside a normal-
|
||||
# looking result bucket.
|
||||
$result = Resolve-UserPersona -UserRecord $user -Rules @(New-NonMatchingRule -Id 'R-010' -Priority 10)
|
||||
|
||||
$result.Outcome | Should -Not -Be 'EvaluationError'
|
||||
$result.EvaluationErrorReason | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'preserves the stored persona on the result for later comparison' {
|
||||
(Resolve-UserPersona -UserRecord $user -Rules @(New-NonMatchingRule -Id 'R-010' -Priority 10)).StoredPersona |
|
||||
Should -Be 'Employee'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
Regression suite for the single most dangerous defect this system can have.
|
||||
|
||||
If a failed membership lookup is ever treated as "not a member", then:
|
||||
|
||||
notMemberOf <break-glass group> -> True
|
||||
notMemberOf <tier 0 group> -> True
|
||||
|
||||
and a privileged account silently classifies as an ordinary user during a
|
||||
transient Graph outage. The write then persists that downgrade to the
|
||||
directory. Every assertion here exists to make that regression fail loudly.
|
||||
#>
|
||||
|
||||
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'
|
||||
|
||||
$script:failedLookupUser = New-PersonaUserRecord `
|
||||
-AccountObjectId '00000000-0000-0000-0000-000000000001' `
|
||||
-UserPrincipalName 'emergency-access-01@example.invalid' `
|
||||
-Properties @{ Department = 'IT' } `
|
||||
-StoredPersona 'Tier0-Admin' `
|
||||
-Membership (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts')
|
||||
}
|
||||
|
||||
Describe 'A failed membership lookup is never a non-match (FR-013)' {
|
||||
|
||||
It 'does not satisfy notMemberOf' {
|
||||
$result = Test-PersonaCondition -UserRecord $failedLookupUser -Condition ([pscustomobject]@{
|
||||
type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($tier0)
|
||||
})
|
||||
|
||||
$result | Should -Be 'Unknown'
|
||||
$result | Should -Not -Be 'True' -Because 'treating unknown membership as "not a member" silently downgrades privileged accounts'
|
||||
}
|
||||
|
||||
It 'does not satisfy memberOf either' {
|
||||
Test-PersonaCondition -UserRecord $failedLookupUser -Condition ([pscustomobject]@{
|
||||
type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0)
|
||||
}) | Should -Be 'Unknown'
|
||||
}
|
||||
|
||||
It 'does not satisfy a role condition' {
|
||||
Test-PersonaCondition -UserRecord $failedLookupUser -Condition ([pscustomobject]@{
|
||||
type = 'role'; operator = 'memberOf'; roleIds = @('<TIER0-ROLE-TEMPLATE-ID>')
|
||||
}) | Should -Be 'Unknown'
|
||||
}
|
||||
|
||||
It 'prevents a notMemberOf rule from classifying a privileged account as ordinary' {
|
||||
# The end-to-end version of the hazard: a "restricted user" rule defined as
|
||||
# "not in the admin group" must not capture an account whose membership is
|
||||
# simply unknown.
|
||||
$restrictedRule = [pscustomobject]@{
|
||||
id = 'R-100'; priority = 100; persona = 'Restricted-User'; enabled = $true
|
||||
match = [pscustomobject]@{
|
||||
operator = 'all'
|
||||
conditions = @([pscustomobject]@{ type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($tier0) })
|
||||
}
|
||||
}
|
||||
|
||||
$result = Resolve-UserPersona -UserRecord $failedLookupUser -Rules @($restrictedRule)
|
||||
|
||||
$result.CalculatedPersona | Should -Not -Be 'Restricted-User'
|
||||
$result.Outcome | Should -Be 'EvaluationError'
|
||||
$result.StoredPersona | Should -Be 'Tier0-Admin'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'An empty successful lookup IS a legitimate non-match' {
|
||||
|
||||
BeforeAll {
|
||||
# "Member of nothing" is a real, knowable answer and must evaluate normally.
|
||||
# Over-applying the fail-safe would make every unaffiliated account an error.
|
||||
$script:noGroupsUser = New-PersonaUserRecord `
|
||||
-AccountObjectId '00000000-0000-0000-0000-000000000104' `
|
||||
-UserPrincipalName 'drew.sparse@example.invalid' `
|
||||
-Membership (New-PersonaMembershipRecord -DirectGroupObjectIds @() -DirectRetrieved)
|
||||
}
|
||||
|
||||
It 'satisfies notMemberOf' {
|
||||
Test-PersonaCondition -UserRecord $noGroupsUser -Condition ([pscustomobject]@{
|
||||
type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($tier0)
|
||||
}) | Should -Be 'True'
|
||||
}
|
||||
|
||||
It 'does not satisfy memberOf' {
|
||||
Test-PersonaCondition -UserRecord $noGroupsUser -Condition ([pscustomobject]@{
|
||||
type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0)
|
||||
}) | Should -Be 'False'
|
||||
}
|
||||
|
||||
It 'produces a normal Matched outcome, not an error' {
|
||||
$restrictedRule = [pscustomobject]@{
|
||||
id = 'R-100'; priority = 100; persona = 'Restricted-User'; enabled = $true
|
||||
match = [pscustomobject]@{
|
||||
operator = 'all'
|
||||
conditions = @([pscustomobject]@{ type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($tier0) })
|
||||
}
|
||||
}
|
||||
|
||||
$result = Resolve-UserPersona -UserRecord $noGroupsUser -Rules @($restrictedRule)
|
||||
$result.Outcome | Should -Be 'Matched'
|
||||
$result.CalculatedPersona | Should -Be 'Restricted-User'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
#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'
|
||||
)) { . (Join-Path $repoRoot $f) }
|
||||
|
||||
# A user whose membership lookup failed: any membership condition against this
|
||||
# record yields Unknown, which is how these tests inject the tri-state.
|
||||
$script:user = New-PersonaUserRecord `
|
||||
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
|
||||
-UserPrincipalName 'alex.employee@example.invalid' `
|
||||
-Properties @{ Department = 'Finance' } `
|
||||
-Membership (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts')
|
||||
|
||||
$script:TRUE_COND = [pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Finance' }
|
||||
$script:FALSE_COND = [pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Legal' }
|
||||
$script:UNKNOWN_COND = [pscustomobject]@{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @('00000000-0000-0000-0000-0000000000a0') }
|
||||
|
||||
function Eval {
|
||||
param([string] $Operator, [object[]] $Conditions)
|
||||
Test-PersonaConditionGroup -Group ([pscustomobject]@{ operator = $Operator; conditions = $Conditions }) -UserRecord $user
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Unknown propagation table (data-model.md)' {
|
||||
|
||||
Context 'the injected conditions behave as intended' {
|
||||
It 'TRUE_COND is True' { Test-PersonaCondition -Condition $TRUE_COND -UserRecord $user | Should -Be 'True' }
|
||||
It 'FALSE_COND is False' { Test-PersonaCondition -Condition $FALSE_COND -UserRecord $user | Should -Be 'False' }
|
||||
It 'UNKNOWN_COND is Unknown' { Test-PersonaCondition -Condition $UNKNOWN_COND -UserRecord $user | Should -Be 'Unknown' }
|
||||
}
|
||||
|
||||
Context 'row 1: all + any False -> False' {
|
||||
It 'a definite non-match decides the group despite an unknown sibling' {
|
||||
# The rule cannot match either way, so degrading to Unknown here would
|
||||
# manufacture EvaluationErrors for rules that were never going to match.
|
||||
Eval 'all' @($FALSE_COND, $UNKNOWN_COND) | Should -Be 'False'
|
||||
}
|
||||
It 'holds regardless of sibling order' {
|
||||
Eval 'all' @($UNKNOWN_COND, $FALSE_COND) | Should -Be 'False'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'row 2: all + only True and Unknown -> Unknown' {
|
||||
It 'cannot confirm a match' {
|
||||
Eval 'all' @($TRUE_COND, $UNKNOWN_COND) | Should -Be 'Unknown'
|
||||
}
|
||||
It 'holds regardless of sibling order' {
|
||||
Eval 'all' @($UNKNOWN_COND, $TRUE_COND) | Should -Be 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'row 3: any + any True -> True' {
|
||||
It 'a definite match decides the group despite an unknown sibling' {
|
||||
Eval 'any' @($TRUE_COND, $UNKNOWN_COND) | Should -Be 'True'
|
||||
}
|
||||
It 'holds regardless of sibling order' {
|
||||
Eval 'any' @($UNKNOWN_COND, $TRUE_COND) | Should -Be 'True'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'row 4: any + only False and Unknown -> Unknown' {
|
||||
It 'cannot rule out a match' {
|
||||
Eval 'any' @($FALSE_COND, $UNKNOWN_COND) | Should -Be 'Unknown'
|
||||
}
|
||||
It 'holds regardless of sibling order' {
|
||||
Eval 'any' @($UNKNOWN_COND, $FALSE_COND) | Should -Be 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'no-unknown baselines' {
|
||||
It 'all with only True is True' { Eval 'all' @($TRUE_COND, $TRUE_COND) | Should -Be 'True' }
|
||||
It 'all with a False is False' { Eval 'all' @($TRUE_COND, $FALSE_COND) | Should -Be 'False' }
|
||||
It 'any with a True is True' { Eval 'any' @($FALSE_COND, $TRUE_COND) | Should -Be 'True' }
|
||||
It 'any with only False is False' { Eval 'any' @($FALSE_COND, $FALSE_COND) | Should -Be 'False' }
|
||||
}
|
||||
|
||||
Context 'propagation through nesting' {
|
||||
It 'carries Unknown up from a nested group' {
|
||||
$inner = [pscustomobject]@{ operator = 'all'; conditions = @($TRUE_COND, $UNKNOWN_COND) }
|
||||
Eval 'all' @($TRUE_COND, $inner) | Should -Be 'Unknown'
|
||||
}
|
||||
|
||||
It 'lets a definite False at the outer level still decide the group' {
|
||||
$inner = [pscustomobject]@{ operator = 'all'; conditions = @($TRUE_COND, $UNKNOWN_COND) }
|
||||
Eval 'all' @($FALSE_COND, $inner) | Should -Be 'False'
|
||||
}
|
||||
|
||||
It 'lets a definite True at the outer level still decide an any group' {
|
||||
$inner = [pscustomobject]@{ operator = 'any'; conditions = @($FALSE_COND, $UNKNOWN_COND) }
|
||||
Eval 'any' @($TRUE_COND, $inner) | Should -Be 'True'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
SC-002: a second consecutive run over unchanged input proposes zero changes.
|
||||
|
||||
Idempotence is what makes the engine safe to schedule. A run that rewrites the
|
||||
same value every time produces directory churn, floods the audit trail, and makes
|
||||
a genuine change indistinguishable from routine noise.
|
||||
#>
|
||||
|
||||
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:config = New-TestRuntimeConfiguration -TargetAttribute $target
|
||||
}
|
||||
|
||||
Describe 'Idempotence across consecutive runs (SC-002)' -Tag 'Safety' {
|
||||
|
||||
BeforeEach {
|
||||
Mock Write-Host { }
|
||||
|
||||
# A mutable population, so the second run genuinely sees what the first wrote
|
||||
# rather than a fresh copy of the original fixtures. Re-reading the same
|
||||
# unchanged fixtures would prove nothing about idempotence.
|
||||
$script:store = @{}
|
||||
foreach ($user in (New-TestPopulation -Count 20 -TargetAttribute $script:target)) {
|
||||
$script:store[$user['id']] = $user
|
||||
}
|
||||
|
||||
Mock Get-PersonaUsers { $script:store.Values }
|
||||
|
||||
Mock Set-UserPersonaAttribute {
|
||||
$script:store[$UserObjectId][$AttributeName] = $Value
|
||||
[pscustomobject]@{ Succeeded = $true; AccountObjectId = $UserObjectId; Value = $Value; PreviousValue = $PreviousValue }
|
||||
}
|
||||
}
|
||||
|
||||
It 'proposes zero changes on the second run' {
|
||||
$first = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$first.Counters.Updated | Should -BeGreaterThan 0
|
||||
|
||||
$second = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$second.Counters.Updated | Should -Be 0
|
||||
$second.Counters.WouldUpdate | Should -Be 0
|
||||
}
|
||||
|
||||
It 'issues no write request at all on the second run' {
|
||||
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$writesAfterFirst = 0
|
||||
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly -Scope It -ParameterFilter { $false }
|
||||
|
||||
$before = $script:store.Values | ForEach-Object { $_[$script:target] }
|
||||
|
||||
$second = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$after = $script:store.Values | ForEach-Object { $_[$script:target] }
|
||||
|
||||
($after -join '|') | Should -Be ($before -join '|')
|
||||
$second.Counters.Unchanged | Should -Be $second.Counters.Processed
|
||||
}
|
||||
|
||||
It 'produces identical counters on the second and third runs' {
|
||||
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$second = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$third = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$third.Counters.Matched | Should -Be $second.Counters.Matched
|
||||
$third.Counters.Unclassified | Should -Be $second.Counters.Unclassified
|
||||
$third.Counters.Unchanged | Should -Be $second.Counters.Unchanged
|
||||
$third.Counters.Updated | Should -Be $second.Counters.Updated
|
||||
}
|
||||
|
||||
It 'treats a case-only difference as a real change, so it converges rather than oscillating' {
|
||||
# Change detection is ordinal (FR-015). A stored 'employee' against a
|
||||
# calculated 'Employee' is corrected once and then stays corrected - the
|
||||
# failure mode this guards against is a run that rewrites it every time.
|
||||
foreach ($user in $script:store.Values) {
|
||||
if ($user[$script:target]) { $user[$script:target] = ([string]$user[$script:target]).ToLowerInvariant() }
|
||||
}
|
||||
|
||||
$first = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$second = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$first.Counters.Updated | Should -BeGreaterThan 0
|
||||
$second.Counters.Updated | Should -Be 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
The write gate has exactly one origin: ShouldProcess.
|
||||
|
||||
The tests that matter here are the negative ones. -Debug and -Verbose are the two
|
||||
switches an operator is most likely to reach for believing they make a run safe,
|
||||
and neither does. If that ever changes silently, someone will run an enforcing
|
||||
pass believing they are looking rather than touching.
|
||||
#>
|
||||
|
||||
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:config = New-TestRuntimeConfiguration -TargetAttribute $target
|
||||
$script:population = @(New-TestPopulation -Count 12 -TargetAttribute $target)
|
||||
|
||||
$script:entryScript = Join-Path $repoRoot 'Invoke-PersonaEngine.ps1'
|
||||
}
|
||||
|
||||
Describe 'Mode derives from the gate alone' -Tag 'Safety' {
|
||||
|
||||
BeforeEach {
|
||||
Mock Get-PersonaUsers { $script:population }
|
||||
Mock Write-Host { }
|
||||
# Two of these tests raise the verbose and debug preferences deliberately.
|
||||
# Without this the per-user diagnostic lines flood the whole suite's output.
|
||||
Mock Write-Verbose { }
|
||||
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
|
||||
}
|
||||
|
||||
It 'writes when -Debug is active and the gate allows it' {
|
||||
# -Debug must not imply read-only. An operator who believed otherwise would
|
||||
# reach for it as a safety control and get an enforcing run.
|
||||
$DebugPreference = 'Continue'
|
||||
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing -Tracing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$outcome.Counters.Updated | Should -BeGreaterThan 0
|
||||
Should -Invoke Set-UserPersonaAttribute -Times $outcome.Counters.Updated -Exactly
|
||||
}
|
||||
|
||||
It 'writes when -Verbose is active and the gate allows it' {
|
||||
$VerbosePreference = 'Continue'
|
||||
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$outcome.Counters.Updated | Should -BeGreaterThan 0
|
||||
}
|
||||
|
||||
It 'refuses every write when the gate refuses, regardless of IsEnforcing' {
|
||||
# IsEnforcing shapes the Action label; the gate decides the write. A
|
||||
# disagreement between them must resolve in favour of not writing.
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $false }
|
||||
|
||||
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
|
||||
$outcome.Counters.Updated | Should -Be 0
|
||||
$outcome.Counters.WouldUpdate | Should -BeGreaterThan 0
|
||||
}
|
||||
|
||||
It 'honours a gate that allows some accounts and refuses others' {
|
||||
# A per-account gate, as ShouldProcess is when the operator answers "Yes"
|
||||
# rather than "Yes to All". Both buckets must be populated in one run.
|
||||
$script:gateCalls = 0
|
||||
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) ($script:gateCalls++ % 2) -eq 0 }
|
||||
|
||||
($outcome.Counters.Updated + $outcome.Counters.WouldUpdate) | Should -BeGreaterThan 0
|
||||
$outcome.Counters.Updated | Should -BeGreaterThan 0
|
||||
$outcome.Counters.WouldUpdate | Should -BeGreaterThan 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'The entry script declares the safety contract it promises' -Tag 'Safety' {
|
||||
|
||||
BeforeAll {
|
||||
$script:entryText = Get-Content -LiteralPath $script:entryScript -Raw
|
||||
}
|
||||
|
||||
It 'declares SupportsShouldProcess with a High confirm impact' {
|
||||
$entryText | Should -Match 'SupportsShouldProcess\s*=\s*\$true'
|
||||
$entryText | Should -Match "ConfirmImpact\s*=\s*'High'"
|
||||
}
|
||||
|
||||
It 'does not declare a preview or no-write parameter of its own' {
|
||||
# Two sources of truth for the write gate is the defect class Principle III
|
||||
# exists to prevent. -WhatIf is the only approved control.
|
||||
$entryText | Should -Not -Match '\[switch\]\s*\$Preview'
|
||||
$entryText | Should -Not -Match '\[switch\]\s*\$NoWrite'
|
||||
$entryText | Should -Not -Match '\[switch\]\s*\$ReadOnly'
|
||||
$entryText | Should -Not -Match '\[switch\]\s*\$DryRun'
|
||||
}
|
||||
|
||||
It 'derives the mode from ShouldProcess' {
|
||||
$entryText | Should -Match '\$PSCmdlet\.ShouldProcess\('
|
||||
}
|
||||
|
||||
It 'does not derive the mode from DebugPreference or WhatIfPreference' {
|
||||
# Reading the preference variables directly would reintroduce a second source
|
||||
# of truth by the back door.
|
||||
$entryText | Should -Not -Match '\$WhatIfPreference'
|
||||
$entryText | Should -Not -Match 'if\s*\(\s*\$DebugPreference'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
SC-004: a preview run issues zero write requests across a full population.
|
||||
|
||||
The assertion that matters is the call count on the write adapter, not the
|
||||
absence of an error. A run that never reached the write path because it crashed
|
||||
at user 3 would also record zero writes, so every test here checks the population
|
||||
was fully processed as well.
|
||||
#>
|
||||
|
||||
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:config = [pscustomobject]@{
|
||||
ConfigVersion = '1.0.0'
|
||||
ConfigurationHash = ('0' * 64)
|
||||
TargetAttribute = $target
|
||||
ApprovedWritableAttributes = @($target)
|
||||
MaxConditionDepth = 5
|
||||
SummaryInterval = 0
|
||||
DefaultMembershipMode = 'Direct'
|
||||
EvaluationErrorThreshold = $null
|
||||
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:population = @(New-TestPopulation -Count 30 -TargetAttribute $target)
|
||||
}
|
||||
|
||||
Describe 'Zero writes in preview mode (SC-004, FR-017)' -Tag 'Safety' {
|
||||
|
||||
BeforeEach {
|
||||
Mock Get-PersonaUsers { $script:population }
|
||||
Mock Set-UserPersonaAttribute { throw 'The write adapter must never be reached in preview mode.' }
|
||||
Mock Write-Host { }
|
||||
}
|
||||
|
||||
It 'issues no write request across the full population' {
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Preview') `
|
||||
-ShouldProcessGate { param($t, $d) $false }
|
||||
|
||||
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'still processed every account, so the zero count is meaningful' {
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Preview') `
|
||||
-ShouldProcessGate { param($t, $d) $false }
|
||||
|
||||
$outcome.Counters.Processed | Should -Be 30
|
||||
}
|
||||
|
||||
It 'reports the intended changes as WouldUpdate rather than hiding them' {
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Preview') `
|
||||
-ShouldProcessGate { param($t, $d) $false }
|
||||
|
||||
# Ten Guest accounts carry a stale stored value of 'Employee'.
|
||||
$outcome.Counters.WouldUpdate | Should -BeGreaterThan 0
|
||||
}
|
||||
|
||||
It 'returns exit code 0 - a preview that changes nothing is a successful run' {
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Preview') `
|
||||
-ShouldProcessGate { param($t, $d) $false }
|
||||
|
||||
$outcome.ExitCode | Should -Be 0
|
||||
}
|
||||
|
||||
It 'defaults to refusing writes when no gate is supplied' {
|
||||
# A caller that forgets the gate must preview, not write. The default is the
|
||||
# safe answer rather than the convenient one.
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Preview')
|
||||
|
||||
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
|
||||
$outcome.Counters.Updated | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Writes do occur when the gate allows them' -Tag 'Safety' {
|
||||
|
||||
BeforeEach {
|
||||
Mock Get-PersonaUsers { $script:population }
|
||||
Mock Write-Host { }
|
||||
Mock Set-UserPersonaAttribute {
|
||||
[pscustomobject]@{ Succeeded = $true; AccountObjectId = $UserObjectId; Value = $Value; PreviousValue = $PreviousValue }
|
||||
}
|
||||
}
|
||||
|
||||
It 'writes exactly the accounts whose calculated value differs' {
|
||||
# The counterpart to the zero-write test. Without this, a run loop that never
|
||||
# writes under any circumstances would pass every assertion above.
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$outcome.Counters.Updated | Should -BeGreaterThan 0
|
||||
Should -Invoke Set-UserPersonaAttribute -Times $outcome.Counters.Updated -Exactly
|
||||
}
|
||||
|
||||
It 'never writes an account whose stored value already matches' {
|
||||
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
|
||||
-Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$outcome.Counters.Unchanged | Should -BeGreaterThan 0
|
||||
($outcome.Counters.Updated + $outcome.Counters.Unchanged + $outcome.Counters.Skipped + $outcome.Counters.UpdateFailed) |
|
||||
Should -Be $outcome.Counters.Processed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
SC-005: every write body has exactly one key, equal to engine.targetAttribute.
|
||||
|
||||
This is the assertion that bounds the blast radius. OTD-003 records that Graph
|
||||
application permissions have no per-property scope: whatever this engine can write
|
||||
to the persona attribute, it could equally write to any other user property. The
|
||||
directory will not stop a body with a second key, so this test is the thing that
|
||||
does.
|
||||
#>
|
||||
|
||||
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:approved = @($script:target)
|
||||
}
|
||||
|
||||
Describe 'Write body construction (SC-005)' -Tag 'Safety' {
|
||||
|
||||
It 'produces a body with exactly one key' {
|
||||
$body = New-PersonaWriteBody -AttributeName $target -Value 'Employee' `
|
||||
-TargetAttribute $target -ApprovedWritableAttributes $approved
|
||||
|
||||
$body.Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'names that key exactly the target attribute' {
|
||||
$body = New-PersonaWriteBody -AttributeName $target -Value 'Employee' `
|
||||
-TargetAttribute $target -ApprovedWritableAttributes $approved
|
||||
|
||||
@($body.Keys)[0] | Should -BeExactly $target
|
||||
}
|
||||
|
||||
It 'carries the calculated value unchanged' {
|
||||
$body = New-PersonaWriteBody -AttributeName $target -Value 'Tier0-Admin' `
|
||||
-TargetAttribute $target -ApprovedWritableAttributes $approved
|
||||
|
||||
$body[$target] | Should -BeExactly 'Tier0-Admin'
|
||||
}
|
||||
|
||||
It 'permits an empty value, which clears the attribute' {
|
||||
# Clearing is a legitimate outcome when a rule set stops matching an account.
|
||||
# It must go through the same single-key path as any other write.
|
||||
$body = New-PersonaWriteBody -AttributeName $target -Value '' `
|
||||
-TargetAttribute $target -ApprovedWritableAttributes $approved
|
||||
|
||||
$body.Count | Should -Be 1
|
||||
$body[$target] | Should -Be ''
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Every body issued during a run has exactly one key' -Tag 'Safety' {
|
||||
|
||||
It 'holds across a full enforcing population' {
|
||||
# The unit test above proves the builder is correct. This proves the run loop
|
||||
# actually uses it, on every account, with no other path to a PATCH.
|
||||
$script:captured = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
Mock Write-Host { }
|
||||
Mock Get-PersonaUsers { @(New-TestPopulation -Count 20 -TargetAttribute $script:target) }
|
||||
Mock Invoke-PersonaGraphRequest {
|
||||
if ($Method -eq 'PATCH') { $script:captured.Add($Body) }
|
||||
@{}
|
||||
}
|
||||
|
||||
$outcome = Invoke-PersonaEngineRun `
|
||||
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
|
||||
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing -ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$script:captured.Count | Should -BeGreaterThan 0
|
||||
$script:captured.Count | Should -Be $outcome.Counters.Updated
|
||||
|
||||
foreach ($body in $script:captured) {
|
||||
$body.Count | Should -Be 1
|
||||
@($body.Keys)[0] | Should -BeExactly $script:target
|
||||
}
|
||||
}
|
||||
|
||||
It 'issues a PATCH and nothing else as a write method' {
|
||||
$script:methods = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
Mock Write-Host { }
|
||||
Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) }
|
||||
Mock Invoke-PersonaGraphRequest {
|
||||
$script:methods.Add($Method)
|
||||
@{}
|
||||
}
|
||||
|
||||
$null = Invoke-PersonaEngineRun `
|
||||
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
|
||||
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing -ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
# No PUT and no DELETE: a PUT would replace the whole user object, and there
|
||||
# is no circumstance in which this engine removes one.
|
||||
$script:methods | Should -Not -Contain 'PUT'
|
||||
$script:methods | Should -Not -Contain 'DELETE'
|
||||
$script:methods | Should -Not -Contain 'POST'
|
||||
$script:methods | Should -Contain 'PATCH'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'New-PersonaWriteBody is the only construction path' -Tag 'Safety' {
|
||||
|
||||
It 'is the only source file that builds a PATCH body' {
|
||||
# A second construction site would make SC-005 a property of a convention
|
||||
# rather than of a testable function.
|
||||
$sources = Get-ChildItem -Path (Join-Path $repoRoot 'src') -Filter '*.ps1' -Recurse -File
|
||||
|
||||
$offenders = foreach ($file in $sources) {
|
||||
if ($file.Name -eq 'New-PersonaWriteBody.ps1') { continue }
|
||||
|
||||
$text = Get-Content -LiteralPath $file.FullName -Raw
|
||||
if ($text -match "Method\s*=?\s*'PATCH'" -and $text -notmatch 'New-PersonaWriteBody') {
|
||||
# Invoke-PersonaGraphRequest declares PATCH in a ValidateSet; it does
|
||||
# not construct a body.
|
||||
if ($file.Name -ne 'Invoke-PersonaGraphRequest.ps1') { $file.Name }
|
||||
}
|
||||
}
|
||||
|
||||
$offenders | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
New-PersonaWriteBody throws for any attribute other than the configured target,
|
||||
and for any target absent from the approved list.
|
||||
|
||||
Throwing rather than correcting is the design. A caller that asked to write the
|
||||
wrong attribute has a defect; silently substituting the right one hides it until
|
||||
the day the substitution is also wrong.
|
||||
#>
|
||||
|
||||
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:approved = @($script:target)
|
||||
}
|
||||
|
||||
Describe 'Rejection of a non-target attribute' -Tag 'Safety' {
|
||||
|
||||
It 'refuses <_>' -ForEach @('department', 'jobTitle', 'userPrincipalName', 'accountEnabled', 'onPremisesImmutableId') {
|
||||
{ New-PersonaWriteBody -AttributeName $_ -Value 'Employee' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
|
||||
Should -Throw -ExpectedMessage '*only the configured target attribute*'
|
||||
}
|
||||
|
||||
It 'refuses a different extension property' {
|
||||
{ New-PersonaWriteBody -AttributeName 'extension_<EXTENSION-APP-ID>_<SOMETHING-ELSE>' -Value 'Employee' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
|
||||
Should -Throw
|
||||
}
|
||||
|
||||
It 'refuses a casing variant of the target' {
|
||||
# Extension property names are case-sensitive in Graph, so this is a different
|
||||
# attribute, not the same one spelled differently. Accepting it would write to
|
||||
# a property nobody approved.
|
||||
{ New-PersonaWriteBody -AttributeName $script:target.ToUpperInvariant() -Value 'Employee' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
|
||||
Should -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Rejection of an unapproved target' -Tag 'Safety' {
|
||||
|
||||
It 'refuses a target absent from the approved list' {
|
||||
{ New-PersonaWriteBody -AttributeName $script:target -Value 'Employee' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes @('extension_<EXTENSION-APP-ID>_<OTHER>') } |
|
||||
Should -Throw -ExpectedMessage '*not present in approvedWritableAttributes*'
|
||||
}
|
||||
|
||||
It 'refuses when the approved list is empty' {
|
||||
{ New-PersonaWriteBody -AttributeName $script:target -Value 'Employee' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes @() } |
|
||||
Should -Throw
|
||||
}
|
||||
|
||||
It 'refuses when the approved list differs only in casing' {
|
||||
{ New-PersonaWriteBody -AttributeName $script:target -Value 'Employee' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes @($script:target.ToUpperInvariant()) } |
|
||||
Should -Throw
|
||||
}
|
||||
|
||||
It 'refuses a blank attribute name' {
|
||||
{ New-PersonaWriteBody -AttributeName '' -Value 'Employee' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
|
||||
Should -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Resolve-TargetAttribute enforces the same rule earlier' -Tag 'Safety' {
|
||||
|
||||
It 'returns the target when it is approved' {
|
||||
$config = [pscustomobject]@{ TargetAttribute = $script:target; ApprovedWritableAttributes = $script:approved }
|
||||
|
||||
Resolve-TargetAttribute -Configuration $config | Should -BeExactly $script:target
|
||||
}
|
||||
|
||||
It 'throws rather than returning null for a blank target' {
|
||||
# A null return would be indistinguishable from a caller forgetting to check,
|
||||
# and that caller writes to whatever name it was holding.
|
||||
$config = [pscustomobject]@{ TargetAttribute = ' '; ApprovedWritableAttributes = $script:approved }
|
||||
|
||||
{ Resolve-TargetAttribute -Configuration $config } | Should -Throw -ExpectedMessage '*blank*'
|
||||
}
|
||||
|
||||
It 'throws for an unapproved target' {
|
||||
$config = [pscustomobject]@{ TargetAttribute = 'department'; ApprovedWritableAttributes = $script:approved }
|
||||
|
||||
{ Resolve-TargetAttribute -Configuration $config } | Should -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Set-UserPersonaAttribute refuses an unconfirmed call' -Tag 'Safety' {
|
||||
|
||||
It 'throws when -Confirmed is absent' {
|
||||
# Reaching the write adapter without the gate is a control-flow defect. The
|
||||
# only safe response is to refuse, not to infer intent.
|
||||
Mock Invoke-PersonaGraphRequest { @{} }
|
||||
|
||||
{ Set-UserPersonaAttribute -UserObjectId '00000000-0000-0000-0000-000000000101' `
|
||||
-AttributeName $script:target -Value 'Employee' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
|
||||
Should -Throw -ExpectedMessage '*without a confirmed ShouldProcess gate*'
|
||||
|
||||
Should -Invoke Invoke-PersonaGraphRequest -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'returns a failed result rather than throwing when the PATCH fails' {
|
||||
# A failed write is a per-user outcome. Throwing would abandon the rest of the
|
||||
# population over one account.
|
||||
Mock Invoke-PersonaGraphRequest { throw 'Response status code does not indicate success: 403 (Forbidden).' }
|
||||
|
||||
$result = Set-UserPersonaAttribute -UserObjectId '00000000-0000-0000-0000-000000000101' `
|
||||
-AttributeName $script:target -Value 'Employee' -PreviousValue 'Guest' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved -Confirmed
|
||||
|
||||
$result.Succeeded | Should -BeFalse
|
||||
$result.FailureReason | Should -Not -BeNullOrEmpty
|
||||
$result.PreviousValue | Should -Be 'Guest'
|
||||
}
|
||||
|
||||
It 'captures previousValue from the value observed before the write' {
|
||||
Mock Invoke-PersonaGraphRequest { @{} }
|
||||
|
||||
$result = Set-UserPersonaAttribute -UserObjectId '00000000-0000-0000-0000-000000000101' `
|
||||
-AttributeName $script:target -Value 'Tier0-Admin' -PreviousValue 'Employee' `
|
||||
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved -Confirmed
|
||||
|
||||
$result.Succeeded | Should -BeTrue
|
||||
$result.PreviousValue | Should -Be 'Employee'
|
||||
$result.Value | Should -Be 'Tier0-Admin'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
|
||||
|
||||
<#
|
||||
FR-016: a write is issued only when all four conditions hold.
|
||||
|
||||
1. Evaluation completed successfully (Outcome is not EvaluationError)
|
||||
2. Calculated differs from stored, ordinal comparison
|
||||
3. The target attribute is non-blank and approved
|
||||
4. ShouldProcess returned true for this user
|
||||
|
||||
Each condition is tested in isolation by failing exactly that one, so a passing
|
||||
result cannot be explained by a different condition having blocked the write.
|
||||
#>
|
||||
|
||||
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>'
|
||||
|
||||
function New-Decision {
|
||||
param(
|
||||
[string] $Outcome = 'Matched',
|
||||
[AllowNull()] [string] $Calculated = 'Tier0-Admin',
|
||||
[AllowNull()] [string] $Stored = 'Employee',
|
||||
[string] $ErrorReason = $null
|
||||
)
|
||||
|
||||
[pscustomobject]@{
|
||||
AccountObjectId = '00000000-0000-0000-0000-000000000101'
|
||||
UserPrincipalName = 'alex@example.invalid'
|
||||
Outcome = $Outcome
|
||||
MatchedRuleId = 'RULE-0030-TIER0'
|
||||
CalculatedPersona = $Calculated
|
||||
StoredPersona = $Stored
|
||||
Action = 'Pending'
|
||||
EvaluationErrorReason = $ErrorReason
|
||||
RulesEvaluated = 2
|
||||
DurationMs = 4
|
||||
ConditionTrace = $null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Condition 1 - evaluation must have succeeded' -Tag 'Safety' {
|
||||
|
||||
It 'skips an EvaluationError user even when the values differ' {
|
||||
$result = Compare-PersonaValue -Result (New-Decision -Outcome 'EvaluationError' -Calculated $null -ErrorReason 'lookup failed') `
|
||||
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
|
||||
|
||||
$result.Action | Should -Be 'Skipped'
|
||||
}
|
||||
|
||||
It 'preserves the stored value on an EvaluationError user (FR-014)' {
|
||||
$result = Compare-PersonaValue -Result (New-Decision -Outcome 'EvaluationError' -Calculated $null -Stored 'Tier0-Admin' -ErrorReason 'lookup failed') `
|
||||
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
|
||||
|
||||
$result.StoredPersona | Should -Be 'Tier0-Admin'
|
||||
$result.Action | Should -Be 'Skipped'
|
||||
}
|
||||
|
||||
It 'never reaches the write adapter for an EvaluationError user' {
|
||||
Mock Write-Host { }
|
||||
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
|
||||
Mock Get-PersonaUsers { @(New-TestPopulation -Count 8 -TargetAttribute $script:target) }
|
||||
Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts' }
|
||||
|
||||
$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 -Mode 'Enforce') -IsEnforcing `
|
||||
-ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$outcome.Counters.EvaluationError | Should -Be $outcome.Counters.Processed
|
||||
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Condition 2 - the value must actually differ' -Tag 'Safety' {
|
||||
|
||||
It 'reports Unchanged when stored and calculated are identical' {
|
||||
$result = Compare-PersonaValue -Result (New-Decision -Calculated 'Employee' -Stored 'Employee') `
|
||||
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
|
||||
|
||||
$result.Action | Should -Be 'Unchanged'
|
||||
}
|
||||
|
||||
It 'treats a case-only difference as a real change' {
|
||||
# Change detection is ordinal (FR-015). Treating these as equal would leave the
|
||||
# directory permanently inconsistent with the rule set.
|
||||
$result = Compare-PersonaValue -Result (New-Decision -Calculated 'Employee' -Stored 'employee') `
|
||||
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
|
||||
|
||||
$result.Action | Should -Be 'Updated'
|
||||
}
|
||||
|
||||
It 'treats a blank stored value against a calculated persona as a change' {
|
||||
$result = Compare-PersonaValue -Result (New-Decision -Calculated 'Employee' -Stored '') `
|
||||
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
|
||||
|
||||
$result.Action | Should -Be 'Updated'
|
||||
}
|
||||
|
||||
It 'treats a null stored value as equal to an empty calculated value' {
|
||||
$result = Compare-PersonaValue -Result (New-Decision -Calculated '' -Stored $null) `
|
||||
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
|
||||
|
||||
$result.Action | Should -Be 'Unchanged'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Condition 3 - the target must be valid and approved' -Tag 'Safety' {
|
||||
|
||||
It 'skips when the target attribute is blank' {
|
||||
$result = Compare-PersonaValue -Result (New-Decision) `
|
||||
-IsEnforcing -TargetAttribute '' -ApprovedWritableAttributes @($target)
|
||||
|
||||
$result.Action | Should -Be 'Skipped'
|
||||
}
|
||||
|
||||
It 'skips when the target is absent from the approved list' {
|
||||
$result = Compare-PersonaValue -Result (New-Decision) `
|
||||
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @('extension_<EXTENSION-APP-ID>_<OTHER>')
|
||||
|
||||
$result.Action | Should -Be 'Skipped'
|
||||
}
|
||||
|
||||
It 'skips when the approved list is empty' {
|
||||
$result = Compare-PersonaValue -Result (New-Decision) `
|
||||
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @()
|
||||
|
||||
$result.Action | Should -Be 'Skipped'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Condition 4 - the gate must have returned true' -Tag 'Safety' {
|
||||
|
||||
It 'reports WouldUpdate rather than Updated when not enforcing' {
|
||||
$result = Compare-PersonaValue -Result (New-Decision) `
|
||||
-TargetAttribute $target -ApprovedWritableAttributes @($target)
|
||||
|
||||
$result.Action | Should -Be 'WouldUpdate'
|
||||
}
|
||||
|
||||
It 'downgrades Updated to WouldUpdate when the per-user gate refuses' {
|
||||
Mock Write-Host { }
|
||||
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
|
||||
Mock Get-PersonaUsers { @(New-TestPopulation -Count 12 -TargetAttribute $script:target) }
|
||||
|
||||
$outcome = Invoke-PersonaEngineRun `
|
||||
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
|
||||
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing -ShouldProcessGate { param($t, $d) $false }
|
||||
|
||||
$outcome.Counters.Updated | Should -Be 0
|
||||
$outcome.Counters.WouldUpdate | Should -BeGreaterThan 0
|
||||
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'reports UpdateFailed when the gate allowed the write but the PATCH failed' {
|
||||
Mock Write-Host { }
|
||||
Mock Get-PersonaUsers { @(New-TestPopulation -Count 12 -TargetAttribute $script:target) }
|
||||
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $false; FailureReason = 'Response status code does not indicate success: 403 (Forbidden).' } }
|
||||
|
||||
$outcome = Invoke-PersonaEngineRun `
|
||||
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
|
||||
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing -ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$outcome.Counters.UpdateFailed | Should -BeGreaterThan 0
|
||||
$outcome.Counters.Updated | Should -Be 0
|
||||
}
|
||||
|
||||
It 'continues the run after a failed write rather than abandoning the population' {
|
||||
Mock Write-Host { }
|
||||
Mock Get-PersonaUsers { @(New-TestPopulation -Count 12 -TargetAttribute $script:target) }
|
||||
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $false; FailureReason = 'Forbidden' } }
|
||||
|
||||
$outcome = Invoke-PersonaEngineRun `
|
||||
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
|
||||
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
|
||||
-IsEnforcing -ShouldProcessGate { param($t, $d) $true }
|
||||
|
||||
$outcome.Counters.Processed | Should -Be 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Fails if the rule engine has acquired a dependency it must not have.
|
||||
|
||||
.DESCRIPTION
|
||||
Constitution Principle IV: the pure rule engine must not depend on Microsoft
|
||||
Graph, authentication, Azure Automation, or console rendering. Directory
|
||||
structure alone does not enforce that — one convenient call is all it takes to
|
||||
make the engine untestable offline, and the failure is silent until someone
|
||||
tries to run the tests without a tenant.
|
||||
|
||||
This check is the enforcement. It runs in CI (pipelines/validate.yml) and is
|
||||
cheap enough to run locally on every change.
|
||||
|
||||
.EXAMPLE
|
||||
./tests/Test-EnginePurity.ps1
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $EnginePath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'src/RuleEngine'),
|
||||
[switch] $PassThru
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$forbidden = @(
|
||||
@{ Name = 'Microsoft Graph call'; Regex = 'Invoke-MgGraphRequest|Connect-MgGraph|graph\.microsoft\.com|Invoke-PersonaGraphRequest' }
|
||||
@{ Name = 'Authentication layer'; Regex = 'Connect-Persona\w+' }
|
||||
@{ Name = 'Data provider layer'; Regex = 'Get-Persona(Users|GroupMembership|DirectoryRoles)' }
|
||||
@{ Name = 'Persistence layer'; Regex = 'Set-UserPersonaAttribute|New-PersonaWriteBody|Compare-PersonaValue' }
|
||||
@{ Name = 'Console rendering'; Regex = 'Write-Host|Write-UserPersonaResult|Write-PersonaSummary' }
|
||||
@{ Name = 'Direct HTTP'; Regex = 'Invoke-RestMethod|Invoke-WebRequest|System\.Net\.Http' }
|
||||
@{ Name = 'Filesystem access'; Regex = 'Get-Content|Set-Content|Out-File|Export-Csv' }
|
||||
@{ Name = 'Non-deterministic input'; Regex = 'Get-Random|Get-Date|\[datetime\]::(Now|UtcNow|Today)|New-Guid' }
|
||||
)
|
||||
|
||||
if (-not (Test-Path $EnginePath)) {
|
||||
Write-Host "Engine path '$EnginePath' does not exist yet - nothing to check." -ForegroundColor Yellow
|
||||
if ($PassThru) { return @() }
|
||||
exit 0
|
||||
}
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($file in Get-ChildItem -Path $EnginePath -Filter '*.ps1' -File -Recurse) {
|
||||
|
||||
# Tokenize rather than scan raw text. Comments in this codebase legitimately
|
||||
# name the forbidden functions when explaining why the engine does not call
|
||||
# them, and a text scan cannot tell a trailing comment from code. The parser
|
||||
# can, exactly.
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$null = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$file.FullName, [ref]$tokens, [ref]$parseErrors)
|
||||
|
||||
if ($parseErrors.Count -gt 0) {
|
||||
$findings.Add([pscustomobject]@{
|
||||
File = $file.Name
|
||||
Line = $parseErrors[0].Extent.StartLineNumber
|
||||
Dependency = 'Parse error'
|
||||
Text = $parseErrors[0].Message
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
# Rebuild each line from its non-comment tokens.
|
||||
$codeByLine = @{}
|
||||
foreach ($token in $tokens) {
|
||||
if ($token.Kind -eq 'Comment') { continue }
|
||||
|
||||
$line = $token.Extent.StartLineNumber
|
||||
if (-not $codeByLine.ContainsKey($line)) { $codeByLine[$line] = [System.Text.StringBuilder]::new() }
|
||||
$null = $codeByLine[$line].Append($token.Text).Append(' ')
|
||||
}
|
||||
|
||||
foreach ($line in ($codeByLine.Keys | Sort-Object)) {
|
||||
$code = $codeByLine[$line].ToString()
|
||||
|
||||
foreach ($rule in $forbidden) {
|
||||
if ($code -match $rule.Regex) {
|
||||
$findings.Add([pscustomobject]@{
|
||||
File = $file.Name
|
||||
Line = $line
|
||||
Dependency = $rule.Name
|
||||
Text = $code.Trim()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($findings.Count -gt 0) {
|
||||
Write-Host "Engine purity check FAILED - $($findings.Count) violation(s) of Principle IV:" -ForegroundColor Red
|
||||
$findings | Format-Table -AutoSize | Out-String | Write-Host
|
||||
Write-Host 'The rule engine must remain testable offline with synthetic data.' -ForegroundColor Red
|
||||
if ($PassThru) { return $findings }
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host 'Engine purity check passed: the rule engine has no forbidden dependencies.' -ForegroundColor Green
|
||||
if ($PassThru) { return @() }
|
||||
exit 0
|
||||
@@ -0,0 +1,138 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Fails if any tracked file contains data that must never be committed (SC-013).
|
||||
|
||||
.DESCRIPTION
|
||||
Constitution: every artifact must be free of organization names, real domains,
|
||||
tenant or subscription IDs, real UPNs or Object IDs, and any secret.
|
||||
|
||||
The scan is intentionally blunt. A false positive costs a placeholder rewrite;
|
||||
a false negative commits tenant data to history, where deleting it later does
|
||||
not undo the disclosure.
|
||||
|
||||
Placeholder GUIDs are permitted: any GUID built only from zeros plus a short
|
||||
hex suffix (00000000-0000-0000-0000-0000000000a0) is obviously synthetic.
|
||||
|
||||
.EXAMPLE
|
||||
./tests/Test-Sanitization.ps1
|
||||
./tests/Test-Sanitization.ps1 -Path ./src
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Path = (Split-Path $PSScriptRoot -Parent),
|
||||
[switch] $PassThru
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# A GUID is synthetic when every character before the final short suffix is 0.
|
||||
$placeholderGuid = '^0{8}-0{4}-0{4}-0{4}-0{8}[0-9a-f]{4}$'
|
||||
|
||||
$patterns = @(
|
||||
@{
|
||||
Name = 'Real GUID (tenant, subscription, object, or group ID)'
|
||||
Regex = '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b'
|
||||
Exclude = $placeholderGuid
|
||||
# A PowerShell module manifest must carry a genuine, unique GUID as its
|
||||
# identity - it is what distinguishes this module from another of the same
|
||||
# name. It identifies the module, not a tenant, and cannot be replaced with a
|
||||
# placeholder without breaking module resolution.
|
||||
ExcludeLine = '^\s*GUID\s*='
|
||||
}
|
||||
@{
|
||||
Name = 'Email address or UPN'
|
||||
# Placeholders such as <USER>@<PRIMARY-DOMAIN> contain angle brackets and
|
||||
# are excluded by requiring word characters on both sides.
|
||||
Regex = '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b'
|
||||
# Reserved, permanently unresolvable domains from RFC 2606 and RFC 6761.
|
||||
# These exist precisely so documentation and fixtures can use an address that
|
||||
# is guaranteed never to reach a real mailbox. Rejecting them would push
|
||||
# fixtures toward something that merely looks fake, which is worse: the
|
||||
# difference between "obviously synthetic" and "probably nobody's" is the
|
||||
# whole point of the reserved list.
|
||||
Exclude = '@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|net|org)|invalid|test|localhost)$'
|
||||
}
|
||||
@{
|
||||
Name = 'onmicrosoft.com domain'
|
||||
Regex = '[A-Za-z0-9-]+\.onmicrosoft\.com'
|
||||
}
|
||||
@{
|
||||
Name = 'Bearer token or JWT'
|
||||
Regex = 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}'
|
||||
}
|
||||
@{
|
||||
Name = 'Assigned secret, password, or key literal'
|
||||
Regex = '(?i)\b(client_?secret|password|api_?key|access_?token)\b\s*[:=]\s*["\x27][^"\x27<][^"\x27]*["\x27]'
|
||||
}
|
||||
@{
|
||||
Name = 'PEM private key block'
|
||||
Regex = '-----BEGIN [A-Z ]*PRIVATE KEY-----'
|
||||
}
|
||||
)
|
||||
|
||||
Push-Location $Path
|
||||
try {
|
||||
# Tracked files AND untracked files that are not gitignored.
|
||||
#
|
||||
# Tracked-only would make this gate useless where it matters most: a leaked
|
||||
# identifier in a file that has not been committed yet is precisely the one worth
|
||||
# catching, and scanning only what is already in history means the scan passes
|
||||
# right up until the commit that makes it too late.
|
||||
#
|
||||
# --exclude-standard keeps gitignored build output and local scratch files out,
|
||||
# so the scan covers exactly what a commit would add.
|
||||
$files = git ls-files --cached --others --exclude-standard 2>$null | Sort-Object -Unique
|
||||
if (-not $files) {
|
||||
throw "Not a git repository or no files to scan under '$Path'."
|
||||
}
|
||||
|
||||
# This scanner necessarily contains the patterns it searches for.
|
||||
$selfName = 'tests/Test-Sanitization.ps1'
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($file in $files) {
|
||||
if ($file -eq $selfName) { continue }
|
||||
if (-not (Test-Path $file -PathType Leaf)) { continue }
|
||||
|
||||
# Skip binaries.
|
||||
if ($file -match '\.(png|jpg|jpeg|gif|ico|pdf|zip|dll|exe|pfx|cer)$') { continue }
|
||||
|
||||
$lineNumber = 0
|
||||
foreach ($line in (Get-Content -LiteralPath $file -ErrorAction SilentlyContinue)) {
|
||||
$lineNumber++
|
||||
foreach ($pattern in $patterns) {
|
||||
# A line-level exemption is narrower than a file-level one on purpose:
|
||||
# exempting a whole file would let a real identifier land anywhere in
|
||||
# it, and the files that need an exemption at all are exactly the ones
|
||||
# worth keeping under scrutiny.
|
||||
if ($pattern.ExcludeLine -and $line -match $pattern.ExcludeLine) { continue }
|
||||
|
||||
foreach ($match in [regex]::Matches($line, $pattern.Regex)) {
|
||||
if ($pattern.Exclude -and $match.Value -match $pattern.Exclude) { continue }
|
||||
|
||||
$findings.Add([pscustomobject]@{
|
||||
File = $file
|
||||
Line = $lineNumber
|
||||
Pattern = $pattern.Name
|
||||
Match = $match.Value
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($findings.Count -gt 0) {
|
||||
Write-Host "Sanitization scan FAILED - $($findings.Count) finding(s):" -ForegroundColor Red
|
||||
$findings | Format-Table -AutoSize | Out-String | Write-Host
|
||||
if ($PassThru) { return $findings }
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host 'Sanitization scan passed: no tenant data, credentials, or real identifiers found.' -ForegroundColor Green
|
||||
if ($PassThru) { return @() }
|
||||
exit 0
|
||||
@@ -0,0 +1,188 @@
|
||||
<#
|
||||
Regenerates the invalid-configuration corpus.
|
||||
|
||||
Each file is a valid configuration with exactly ONE thing broken, named after the
|
||||
finding code it must produce. One defect per file is the point: a fixture with two
|
||||
problems cannot prove which one produced the finding, and a validator that
|
||||
reported the wrong code would still pass.
|
||||
|
||||
Run this only when adding a condition. The generated files are committed, so a
|
||||
reviewer sees the fixture in the diff rather than a script that produces it.
|
||||
|
||||
pwsh ./tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $OutputDirectory = $PSScriptRoot
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function New-BaseDocument {
|
||||
@{
|
||||
configVersion = '1.0.0'
|
||||
engine = @{
|
||||
targetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
|
||||
approvedWritableAttributes = @('extension_<EXTENSION-APP-ID>_<PERSONA>')
|
||||
maxConditionDepth = 5
|
||||
summaryInterval = 25
|
||||
defaultMembershipMode = 'direct'
|
||||
}
|
||||
dataSources = @{
|
||||
groups = @{ enabled = $true }
|
||||
roles = @{ enabled = $true }
|
||||
}
|
||||
logging = @{ destination = 'stream'; traceConditionValues = $false }
|
||||
personas = @('Employee', 'Guest', 'Tier0-Admin')
|
||||
rules = @(
|
||||
@{
|
||||
id = 'RULE-0010-GUEST'; name = 'Guest accounts'; description = 'Accounts whose user type is Guest.'
|
||||
enabled = $true; priority = 10; persona = 'Guest'
|
||||
match = @{ operator = 'all'; conditions = @(@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }) }
|
||||
}
|
||||
@{
|
||||
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; description = 'Default classification for member accounts.'
|
||||
enabled = $true; priority = 900; persona = 'Employee'
|
||||
match = @{ operator = 'all'; conditions = @(@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function New-MembershipRule {
|
||||
param([hashtable] $Condition)
|
||||
|
||||
@{
|
||||
id = 'RULE-0030-TIER0'; name = 'Tier 0 administrators'; description = 'Members of the Tier 0 group.'
|
||||
enabled = $true; priority = 30; persona = 'Tier0-Admin'
|
||||
match = @{ operator = 'all'; conditions = @($Condition) }
|
||||
}
|
||||
}
|
||||
|
||||
$cases = [ordered]@{}
|
||||
|
||||
# ---------------------------------------------------------------- VR-002
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules[1].id = 'RULE-0010-GUEST'
|
||||
$cases['PE-SEM-001-duplicate-rule-id'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules[1].priority = 10
|
||||
$cases['PE-SEM-002-duplicate-priority'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
foreach ($rule in $d.rules) { $rule.enabled = $false }
|
||||
$cases['PE-SEM-003-no-enabled-rules'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.engine.targetAttribute = ' '
|
||||
$cases['PE-SEM-004-blank-target-attribute'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.engine.approvedWritableAttributes = @('extension_<EXTENSION-APP-ID>_<SOMETHING-ELSE>')
|
||||
$cases['PE-SEM-005-target-not-approved'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.dataSources.roles.enabled = $false
|
||||
$d.rules += New-MembershipRule -Condition @{ type = 'role'; operator = 'memberOf'; roleIds = @('<TIER0-ROLE-TEMPLATE-ID>') }
|
||||
$cases['PE-SEM-006-unavailable-data-source'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules += New-MembershipRule -Condition @{ type = 'membership'; operator = 'memberOf' }
|
||||
$cases['PE-SEM-007-memberof-without-groups'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules[0].match.conditions[0] = @{ type = 'property'; property = 'UserType'; operator = 'in' }
|
||||
$cases['PE-SEM-008-in-without-values'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules[1].match.conditions[0] = @{ type = 'property'; property = 'Department'; operator = 'isNotNull'; value = 'Finance' }
|
||||
$cases['PE-SEM-009-isnull-with-value'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules[1].persona = 'Undeclared-Persona'
|
||||
$cases['PE-SEM-010-undeclared-persona'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules[1].persona = 'Unclassified'
|
||||
$cases['PE-SEM-011-unclassified-as-persona'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.engine.maxConditionDepth = 2
|
||||
$d.rules[0].match = @{
|
||||
operator = 'all'
|
||||
conditions = @(
|
||||
@{ operator = 'all'; conditions = @(
|
||||
@{ operator = 'all'; conditions = @(
|
||||
@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }
|
||||
) }
|
||||
) }
|
||||
)
|
||||
}
|
||||
$cases['PE-SEM-012-depth-over-configured-maximum'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.engine.maxConditionDepth = 25
|
||||
$cases['PE-SEM-013-depth-over-hard-ceiling'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.dataSources.groups.membershipMode = 'direct'
|
||||
$d.rules += New-MembershipRule -Condition @{
|
||||
type = 'membership'; operator = 'memberOf'; membershipMode = 'transitive'
|
||||
groupObjectIds = @('00000000-0000-0000-0000-0000000000a0')
|
||||
}
|
||||
$cases['PE-SEM-014-mode-not-enabled-globally'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules[1].match.conditions[0] = @{ type = 'property'; property = 'employeeHireDate'; operator = 'isNotNull' }
|
||||
$cases['PE-SEM-015-unsupported-property'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules[1].match.conditions[0] = @{ type = 'property'; property = 'Department'; operator = 'matchesRegex'; value = '[unclosed' }
|
||||
$cases['PE-SEM-016-invalid-regex'] = $d
|
||||
|
||||
# ---------------------------------------------------------------- VR-003
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.engine.targetAttribute = ''
|
||||
$d.engine.approvedWritableAttributes = @('extension_<EXTENSION-APP-ID>_<PERSONA>')
|
||||
$cases['PE-SAF-001-blank-target-production'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.engine.targetAttribute = 'department'
|
||||
$d.engine.approvedWritableAttributes = @('department')
|
||||
$cases['PE-SAF-002-unsupported-writable-attribute'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.dataSources.groups.enabled = $false
|
||||
$d.rules += New-MembershipRule -Condition @{
|
||||
type = 'membership'; operator = 'memberOf'; groupObjectIds = @('00000000-0000-0000-0000-0000000000a0')
|
||||
}
|
||||
$cases['PE-SAF-003-group-rules-without-group-retrieval'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.configVersion = '0.9.0'
|
||||
$cases['PE-SAF-004-version-downgrade'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.rules = @($d.rules[0])
|
||||
$cases['PE-SAF-005-rule-removed-without-version-change'] = $d
|
||||
|
||||
$d = New-BaseDocument
|
||||
$d.logging.traceConditionValues = $true
|
||||
$cases['PE-SAF-006-tracing-without-acknowledgement'] = $d
|
||||
|
||||
# ---------------------------------------------------------------- baseline
|
||||
|
||||
# The comparison baseline for PE-SAF-004 and PE-SAF-005. Valid on its own.
|
||||
$d = New-BaseDocument
|
||||
$cases['baseline-deployed'] = $d
|
||||
|
||||
foreach ($name in $cases.Keys) {
|
||||
$path = Join-Path $OutputDirectory "$name.json"
|
||||
Set-Content -LiteralPath $path -Value ($cases[$name] | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
||||
Write-Host "wrote $name.json"
|
||||
}
|
||||
|
||||
Write-Host ("{0} fixture(s) written to {1}" -f $cases.Count, $OutputDirectory)
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"targetAttribute": "",
|
||||
"maxConditionDepth": 5,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
],
|
||||
"defaultMembershipMode": "direct",
|
||||
"summaryInterval": 25
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"targetAttribute": "department",
|
||||
"maxConditionDepth": 5,
|
||||
"approvedWritableAttributes": [
|
||||
"department"
|
||||
],
|
||||
"defaultMembershipMode": "direct",
|
||||
"summaryInterval": 25
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": false
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0030-TIER0",
|
||||
"persona": "Tier0-Admin",
|
||||
"description": "Members of the Tier 0 group.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "memberOf",
|
||||
"type": "membership",
|
||||
"groupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000a0"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 30,
|
||||
"enabled": true,
|
||||
"name": "Tier 0 administrators"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "0.9.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"traceConditionValues": true,
|
||||
"destination": "stream"
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"persona": "Employee",
|
||||
"enabled": true,
|
||||
"priority": 900,
|
||||
"description": "Default classification for member accounts.",
|
||||
"name": "Employees",
|
||||
"id": "RULE-0010-GUEST"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"persona": "Employee",
|
||||
"enabled": true,
|
||||
"priority": 10,
|
||||
"description": "Default classification for member accounts.",
|
||||
"name": "Employees",
|
||||
"id": "RULE-0900-EMPLOYEE"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"persona": "Guest",
|
||||
"enabled": false,
|
||||
"priority": 10,
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"name": "Guest accounts",
|
||||
"id": "RULE-0010-GUEST"
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"persona": "Employee",
|
||||
"enabled": false,
|
||||
"priority": 900,
|
||||
"description": "Default classification for member accounts.",
|
||||
"name": "Employees",
|
||||
"id": "RULE-0900-EMPLOYEE"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"targetAttribute": " ",
|
||||
"maxConditionDepth": 5,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
],
|
||||
"defaultMembershipMode": "direct",
|
||||
"summaryInterval": 25
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"maxConditionDepth": 5,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<SOMETHING-ELSE>"
|
||||
],
|
||||
"defaultMembershipMode": "direct",
|
||||
"summaryInterval": 25
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0030-TIER0",
|
||||
"persona": "Tier0-Admin",
|
||||
"description": "Members of the Tier 0 group.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "memberOf",
|
||||
"type": "role",
|
||||
"roleIds": [
|
||||
"<TIER0-ROLE-TEMPLATE-ID>"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 30,
|
||||
"enabled": true,
|
||||
"name": "Tier 0 administrators"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0030-TIER0",
|
||||
"persona": "Tier0-Admin",
|
||||
"description": "Members of the Tier 0 group.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "membership",
|
||||
"operator": "memberOf"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 30,
|
||||
"enabled": true,
|
||||
"name": "Tier 0 administrators"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "in",
|
||||
"type": "property",
|
||||
"property": "UserType"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department",
|
||||
"value": "Finance"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"persona": "Undeclared-Persona",
|
||||
"enabled": true,
|
||||
"priority": 900,
|
||||
"description": "Default classification for member accounts.",
|
||||
"name": "Employees",
|
||||
"id": "RULE-0900-EMPLOYEE"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"persona": "Unclassified",
|
||||
"enabled": true,
|
||||
"priority": 900,
|
||||
"description": "Default classification for member accounts.",
|
||||
"name": "Employees",
|
||||
"id": "RULE-0900-EMPLOYEE"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"maxConditionDepth": 2,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
],
|
||||
"defaultMembershipMode": "direct",
|
||||
"summaryInterval": 25
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"persona": "Guest",
|
||||
"enabled": true,
|
||||
"priority": 10,
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"name": "Guest accounts",
|
||||
"id": "RULE-0010-GUEST"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"maxConditionDepth": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
],
|
||||
"defaultMembershipMode": "direct",
|
||||
"summaryInterval": 25
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"membershipMode": "direct",
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0030-TIER0",
|
||||
"persona": "Tier0-Admin",
|
||||
"description": "Members of the Tier 0 group.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "memberOf",
|
||||
"type": "membership",
|
||||
"membershipMode": "transitive",
|
||||
"groupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000a0"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 30,
|
||||
"enabled": true,
|
||||
"name": "Tier 0 administrators"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "employeeHireDate"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "matchesRegex",
|
||||
"type": "property",
|
||||
"property": "Department",
|
||||
"value": "[unclosed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"engine": {
|
||||
"maxConditionDepth": 5,
|
||||
"defaultMembershipMode": "direct",
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
|
||||
"summaryInterval": 25,
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<PERSONA>"
|
||||
]
|
||||
},
|
||||
"personas": [
|
||||
"Employee",
|
||||
"Guest",
|
||||
"Tier0-Admin"
|
||||
],
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "stream",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-GUEST",
|
||||
"persona": "Guest",
|
||||
"description": "Accounts whose user type is Guest.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "equals",
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"name": "Guest accounts"
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"persona": "Employee",
|
||||
"description": "Default classification for member accounts.",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"operator": "isNotNull",
|
||||
"type": "property",
|
||||
"property": "Department"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": 900,
|
||||
"enabled": true,
|
||||
"name": "Employees"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"_comment": "Synthetic membership fixtures keyed by user fixtureId. Each record carries three independently-retrieved facets (RE-007). The 'transitive-failed' and 'all-failed' entries are the most important here: they are what prove an unknown lookup never becomes a false non-match (FR-013), and that a failure in one facet does not contaminate the others.",
|
||||
"groups": {
|
||||
"tier0": "00000000-0000-0000-0000-0000000000a0",
|
||||
"serviceAccounts": "00000000-0000-0000-0000-0000000000b0",
|
||||
"testAccounts": "00000000-0000-0000-0000-0000000000c0",
|
||||
"restricted": "00000000-0000-0000-0000-0000000000d0"
|
||||
},
|
||||
"memberships": [
|
||||
{
|
||||
"fixtureId": "enabled-employee",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [],
|
||||
"directoryRoleIds": [],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "disabled-employee",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [],
|
||||
"directoryRoleIds": [],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "guest",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [],
|
||||
"directoryRoleIds": [],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "null-properties",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [],
|
||||
"directoryRoleIds": [],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "missing-properties",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [],
|
||||
"directoryRoleIds": [],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "mixed-case-properties",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [],
|
||||
"directoryRoleIds": [],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "contractor",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [],
|
||||
"directoryRoleIds": [],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "service-account",
|
||||
"directGroupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000b0"
|
||||
],
|
||||
"transitiveGroupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000b0"
|
||||
],
|
||||
"directoryRoleIds": [],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "breakglass",
|
||||
"directGroupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000a0"
|
||||
],
|
||||
"transitiveGroupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000a0"
|
||||
],
|
||||
"directoryRoleIds": [
|
||||
"<TIER0-ROLE-TEMPLATE-ID>"
|
||||
],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "tier0-admin",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000a0"
|
||||
],
|
||||
"directoryRoleIds": [],
|
||||
"allRetrieved": true
|
||||
},
|
||||
{
|
||||
"fixtureId": "transitive-failed",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [],
|
||||
"directoryRoleIds": [],
|
||||
"directRetrieved": true,
|
||||
"rolesRetrieved": true,
|
||||
"transitiveFailureReason": "Graph 503 after 5 attempts"
|
||||
},
|
||||
{
|
||||
"fixtureId": "all-failed",
|
||||
"directGroupObjectIds": [],
|
||||
"transitiveGroupObjectIds": [],
|
||||
"directoryRoleIds": [],
|
||||
"directFailureReason": "Graph 503 after 5 attempts",
|
||||
"transitiveFailureReason": "Graph 503 after 5 attempts",
|
||||
"rolesFailureReason": "Graph 503 after 5 attempts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"_comment": "Synthetic fixtures. Every value is fictional and every identifier is a placeholder-shaped GUID. These must never be replaced with real directory data (SC-013).",
|
||||
"users": [
|
||||
{
|
||||
"fixtureId": "enabled-employee",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000101",
|
||||
"userPrincipalName": "alex.employee@example.invalid",
|
||||
"displayName": "Alex Employee",
|
||||
"userType": "Member",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": "<ORGANIZATION-NAME>",
|
||||
"Department": "Finance",
|
||||
"JobTitle": "Analyst"
|
||||
},
|
||||
"storedPersona": "Employee"
|
||||
},
|
||||
{
|
||||
"fixtureId": "disabled-employee",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000102",
|
||||
"userPrincipalName": "blair.disabled@example.invalid",
|
||||
"displayName": "Blair Disabled",
|
||||
"userType": "Member",
|
||||
"accountEnabled": false,
|
||||
"properties": {
|
||||
"CompanyName": "<ORGANIZATION-NAME>",
|
||||
"Department": "Operations",
|
||||
"JobTitle": "Coordinator"
|
||||
},
|
||||
"storedPersona": "Employee"
|
||||
},
|
||||
{
|
||||
"fixtureId": "guest",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000103",
|
||||
"userPrincipalName": "casey.guest@example.invalid",
|
||||
"displayName": "Casey Guest",
|
||||
"userType": "Guest",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": "<PARTNER-ORGANIZATION>",
|
||||
"Department": null
|
||||
},
|
||||
"storedPersona": null
|
||||
},
|
||||
{
|
||||
"fixtureId": "null-properties",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000104",
|
||||
"userPrincipalName": "drew.sparse@example.invalid",
|
||||
"displayName": "Drew Sparse",
|
||||
"userType": "Member",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": null,
|
||||
"Department": null,
|
||||
"JobTitle": null
|
||||
},
|
||||
"storedPersona": null
|
||||
},
|
||||
{
|
||||
"fixtureId": "missing-properties",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000105",
|
||||
"userPrincipalName": "ellis.minimal@example.invalid",
|
||||
"displayName": "Ellis Minimal",
|
||||
"userType": "Member",
|
||||
"accountEnabled": true,
|
||||
"properties": {},
|
||||
"storedPersona": null
|
||||
},
|
||||
{
|
||||
"fixtureId": "service-account",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000106",
|
||||
"userPrincipalName": "svc-billing@example.invalid",
|
||||
"displayName": "Billing Service",
|
||||
"userType": "Member",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": "<ORGANIZATION-NAME>",
|
||||
"Department": "IT"
|
||||
},
|
||||
"storedPersona": "Employee"
|
||||
},
|
||||
{
|
||||
"fixtureId": "contractor",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000107",
|
||||
"userPrincipalName": "flynn.external@example.invalid",
|
||||
"displayName": "Flynn External",
|
||||
"userType": "Member",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": "<PARTNER-ORGANIZATION>",
|
||||
"Department": "Engineering"
|
||||
},
|
||||
"storedPersona": null
|
||||
},
|
||||
{
|
||||
"fixtureId": "breakglass",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000001",
|
||||
"userPrincipalName": "emergency-access-01@example.invalid",
|
||||
"displayName": "Emergency Access 01",
|
||||
"userType": "Member",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": "<ORGANIZATION-NAME>",
|
||||
"Department": "IT"
|
||||
},
|
||||
"storedPersona": "BreakGlass-Admin"
|
||||
},
|
||||
{
|
||||
"fixtureId": "mixed-case-properties",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000108",
|
||||
"userPrincipalName": "GRAY.MixedCase@example.invalid",
|
||||
"displayName": "Gray MixedCase",
|
||||
"userType": "MEMBER",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": "<organization-name>",
|
||||
"Department": "FINANCE"
|
||||
},
|
||||
"storedPersona": "employee"
|
||||
},
|
||||
{
|
||||
"fixtureId": "tier0-admin",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000109",
|
||||
"userPrincipalName": "harper.admin@example.invalid",
|
||||
"displayName": "Harper Admin",
|
||||
"userType": "Member",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": "<ORGANIZATION-NAME>",
|
||||
"Department": "IT",
|
||||
"JobTitle": "Directory Administrator"
|
||||
},
|
||||
"storedPersona": "Employee"
|
||||
},
|
||||
{
|
||||
"fixtureId": "transitive-failed",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000110",
|
||||
"userPrincipalName": "indigo.partial@example.invalid",
|
||||
"displayName": "Indigo Partial",
|
||||
"userType": "Member",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": "<ORGANIZATION-NAME>",
|
||||
"Department": "Operations"
|
||||
},
|
||||
"storedPersona": "Tier0-Admin"
|
||||
},
|
||||
{
|
||||
"fixtureId": "all-failed",
|
||||
"accountObjectId": "00000000-0000-0000-0000-000000000111",
|
||||
"userPrincipalName": "jordan.unknown@example.invalid",
|
||||
"displayName": "Jordan Unknown",
|
||||
"userType": "Member",
|
||||
"accountEnabled": true,
|
||||
"properties": {
|
||||
"CompanyName": "<ORGANIZATION-NAME>",
|
||||
"Department": "Finance"
|
||||
},
|
||||
"storedPersona": "Tier0-Admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
<#
|
||||
Shared test helpers.
|
||||
|
||||
Loads the module's source files by dot-sourcing each layer rather than importing
|
||||
PersonaEngine.psd1. Two reasons, both load-bearing:
|
||||
|
||||
1. The manifest declares Microsoft.Graph.Authentication as a required module.
|
||||
Importing it would pull that module into the session, and SC-008 requires
|
||||
the offline suites to run with no Graph module loaded at all. Dot-sourcing
|
||||
is the practical proof that the pure layers do not need it.
|
||||
|
||||
2. Pester's Mock replaces functions in the scope where they are defined.
|
||||
Dot-sourced functions land in the test file's scope, so mocking
|
||||
Get-PersonaUsers or Set-UserPersonaAttribute works without -ModuleName
|
||||
plumbing on every call.
|
||||
|
||||
Nothing here fabricates engine behaviour. The functions under test are the
|
||||
shipped ones; only the Graph boundary is replaced.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command 'Invoke-MgGraphRequest' -ErrorAction SilentlyContinue)) {
|
||||
function Invoke-MgGraphRequest {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stand-in for the real cmdlet when Microsoft.Graph.Authentication is absent.
|
||||
|
||||
.DESCRIPTION
|
||||
SC-008 requires the offline suites to run with no Graph module loaded, and
|
||||
Pester cannot mock a command that does not exist. This stub gives Mock
|
||||
something to replace.
|
||||
|
||||
It throws if it is ever actually called. A stub that returned plausible
|
||||
data would let a test pass while silently exercising nothing, which is
|
||||
worse than no test at all.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Uri,
|
||||
[string] $Method,
|
||||
[object] $Body,
|
||||
[string] $ContentType,
|
||||
[hashtable] $Headers,
|
||||
[string] $OutputType
|
||||
)
|
||||
|
||||
throw 'Invoke-MgGraphRequest stub was called without being mocked. A test reached the real Graph boundary.'
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersonaSourceFile {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the module's source files in load order, for the caller to dot-source.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns paths rather than dot-sourcing them itself. Dot-sourcing inside a
|
||||
function loads into that function's scope, which disappears when it returns -
|
||||
the functions would be defined and immediately unreachable. The caller has to
|
||||
do it:
|
||||
|
||||
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
|
||||
|
||||
The Authentication layer is excluded by default: it is the one layer that
|
||||
calls Connect-MgGraph, and loading it is unnecessary for any offline suite.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $RepoRoot,
|
||||
|
||||
[string[]] $Layers = @(
|
||||
'Normalization', 'Configuration', 'RuleEngine',
|
||||
'DataProviders', 'Persistence', 'Presentation', 'Engine', 'Audit'
|
||||
)
|
||||
)
|
||||
|
||||
$files = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
foreach ($layer in $Layers) {
|
||||
$path = Join-Path $RepoRoot "src/$layer"
|
||||
if (-not (Test-Path $path)) { continue }
|
||||
|
||||
foreach ($file in (Get-ChildItem -Path $path -Filter '*.ps1' -File | Sort-Object Name)) {
|
||||
$files.Add($file.FullName)
|
||||
}
|
||||
}
|
||||
|
||||
, $files.ToArray()
|
||||
}
|
||||
|
||||
function New-TestConfigurationDocument {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds a minimal valid configuration document as a hashtable.
|
||||
|
||||
.DESCRIPTION
|
||||
Tests that need an INVALID configuration start from this and break exactly
|
||||
one thing, so the finding under test is unambiguously caused by that one
|
||||
change rather than by an unrelated defect in a hand-written fixture.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([hashtable])]
|
||||
param()
|
||||
|
||||
@{
|
||||
configVersion = '1.0.0'
|
||||
engine = @{
|
||||
targetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
|
||||
approvedWritableAttributes = @('extension_<EXTENSION-APP-ID>_<PERSONA>')
|
||||
maxConditionDepth = 5
|
||||
summaryInterval = 25
|
||||
defaultMembershipMode = 'direct'
|
||||
}
|
||||
dataSources = @{
|
||||
groups = @{ enabled = $true }
|
||||
roles = @{ enabled = $true }
|
||||
}
|
||||
logging = @{ destination = 'stream' }
|
||||
personas = @('Employee', 'Guest', 'Tier0-Admin')
|
||||
rules = @(
|
||||
@{
|
||||
id = 'RULE-0010-GUEST'
|
||||
name = 'Guest accounts'
|
||||
description = 'Accounts whose user type is Guest.'
|
||||
enabled = $true
|
||||
priority = 10
|
||||
persona = 'Guest'
|
||||
match = @{
|
||||
operator = 'all'
|
||||
conditions = @(
|
||||
@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }
|
||||
)
|
||||
}
|
||||
}
|
||||
@{
|
||||
id = 'RULE-0900-EMPLOYEE'
|
||||
name = 'Employees'
|
||||
description = 'Default classification for member accounts with a department.'
|
||||
enabled = $true
|
||||
priority = 900
|
||||
persona = 'Employee'
|
||||
match = @{
|
||||
operator = 'all'
|
||||
conditions = @(
|
||||
@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function Save-TestConfiguration {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes a configuration document to a temporary file and returns its path.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [hashtable] $Document,
|
||||
[string] $Directory = ([System.IO.Path]::GetTempPath())
|
||||
)
|
||||
|
||||
$path = Join-Path $Directory ("pe-test-{0}.json" -f [guid]::NewGuid().ToString('N'))
|
||||
Set-Content -LiteralPath $path -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
||||
$path
|
||||
}
|
||||
|
||||
function New-TestGraphUser {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds a raw Graph-shaped user hashtable.
|
||||
|
||||
.DESCRIPTION
|
||||
A hashtable, because that is what Invoke-MgGraphRequest returns. Building
|
||||
fixtures in the shape the real boundary produces is what makes the
|
||||
normalization tests meaningful.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([hashtable])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Id,
|
||||
[Parameter(Mandatory)] [string] $UserPrincipalName,
|
||||
[string] $DisplayName = 'Test Account',
|
||||
[string] $UserType = 'Member',
|
||||
[bool] $AccountEnabled = $true,
|
||||
[string] $Department = 'Finance',
|
||||
[string] $CompanyName = '<ORGANIZATION-NAME>',
|
||||
[string] $TargetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>',
|
||||
[AllowEmptyString()] [string] $StoredPersona = ''
|
||||
)
|
||||
|
||||
@{
|
||||
id = $Id
|
||||
userPrincipalName = $UserPrincipalName
|
||||
displayName = $DisplayName
|
||||
userType = $UserType
|
||||
accountEnabled = $AccountEnabled
|
||||
department = $Department
|
||||
companyName = $CompanyName
|
||||
$TargetAttribute = $StoredPersona
|
||||
}
|
||||
}
|
||||
|
||||
function New-TestPopulation {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds a synthetic population spanning every outcome and action.
|
||||
|
||||
.DESCRIPTION
|
||||
Deliberately includes accounts that produce Matched, Unclassified, and - via
|
||||
the membership fixture wired by the caller - EvaluationError, plus accounts
|
||||
whose stored value already matches and accounts whose value would change. A
|
||||
zero-write assertion over a population where nothing would change proves
|
||||
nothing.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([object[]])]
|
||||
param(
|
||||
[int] $Count = 30,
|
||||
[string] $TargetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
|
||||
)
|
||||
|
||||
$users = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
for ($i = 1; $i -le $Count; $i++) {
|
||||
$id = '00000000-0000-0000-0000-{0:d12}' -f $i
|
||||
|
||||
# Every third account is a Guest whose stored value is stale, so a change is
|
||||
# proposed. Every fifth has no department and matches nothing.
|
||||
$isGuest = ($i % 3) -eq 0
|
||||
$isUnclassifiable = -not $isGuest -and ($i % 5) -eq 0
|
||||
|
||||
$users.Add((New-TestGraphUser `
|
||||
-Id $id `
|
||||
-UserPrincipalName ("user{0:d4}@example.invalid" -f $i) `
|
||||
-UserType ($isGuest ? 'Guest' : 'Member') `
|
||||
-Department ($isUnclassifiable ? $null : 'Finance') `
|
||||
-TargetAttribute $TargetAttribute `
|
||||
-StoredPersona ($isGuest ? 'Employee' : ($isUnclassifiable ? '' : 'Employee'))))
|
||||
}
|
||||
|
||||
# Emitted unwrapped, so a Mock body returning this call unrolls into the pipeline
|
||||
# the way a real Get-PersonaUsers does. The `, $array` idiom would emit one
|
||||
# object containing the array, and @() around the call would then produce a
|
||||
# single-element population - a mistake that makes a 30-user test silently a
|
||||
# 1-user test.
|
||||
$users.ToArray()
|
||||
}
|
||||
|
||||
function New-TestRuntimeConfiguration {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the configuration object shape Import-PersonaConfiguration produces.
|
||||
|
||||
.DESCRIPTION
|
||||
The run loop consumes the imported object, not the raw JSON document. Tests
|
||||
that exercise the loop build this directly so they are not also testing the
|
||||
importer - a failure here should mean the loop is wrong, not that the parser
|
||||
changed.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[string] $TargetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>',
|
||||
[int] $SummaryInterval = 0,
|
||||
[AllowNull()] [object] $EvaluationErrorThreshold = $null,
|
||||
[object[]] $Rules,
|
||||
[string] $DefaultMembershipMode = 'Direct'
|
||||
)
|
||||
|
||||
if (-not $Rules) {
|
||||
$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' })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
ConfigVersion = '1.0.0'
|
||||
ConfigurationHash = ('0' * 64)
|
||||
SourcePath = '<CONFIG-PATH>'
|
||||
TargetAttribute = $TargetAttribute
|
||||
ApprovedWritableAttributes = @($TargetAttribute)
|
||||
MaxConditionDepth = 5
|
||||
SummaryInterval = $SummaryInterval
|
||||
DefaultMembershipMode = $DefaultMembershipMode
|
||||
EvaluationErrorThreshold = $EvaluationErrorThreshold
|
||||
Rules = $Rules
|
||||
}
|
||||
}
|
||||
|
||||
function New-TestMembershipRule {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
A rule requiring group membership, so a failed lookup becomes EvaluationError.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $GroupObjectId = '00000000-0000-0000-0000-0000000000a0',
|
||||
[int] $Priority = 30,
|
||||
[string] $Persona = 'Tier0-Admin'
|
||||
)
|
||||
|
||||
[pscustomobject]@{
|
||||
id = 'RULE-0030-TIER0'; name = 'Tier 0 administrators'; enabled = $true
|
||||
priority = $Priority; persona = $Persona
|
||||
match = [pscustomobject]@{
|
||||
operator = 'all'
|
||||
conditions = @([pscustomobject]@{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($GroupObjectId) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CapturedAuditRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts audit records from a captured Information stream.
|
||||
|
||||
.DESCRIPTION
|
||||
Write-PersonaAuditRecord emits records on the Information stream so the
|
||||
success stream stays free for the run outcome. Captured entries arrive as
|
||||
InformationRecord wrappers; this unwraps them and optionally filters by type.
|
||||
|
||||
Usage:
|
||||
|
||||
$info = $null
|
||||
$outcome = Invoke-PersonaEngineRun ... -InformationVariable info
|
||||
$events = Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[AllowNull()] [object] $Captured,
|
||||
[string] $RecordType
|
||||
)
|
||||
|
||||
$records = foreach ($entry in @($Captured)) {
|
||||
$record = ($entry -is [System.Management.Automation.InformationRecord]) ? $entry.MessageData : $entry
|
||||
if ($record -is [System.Collections.IDictionary]) { $record }
|
||||
}
|
||||
|
||||
# Both returns use the comma idiom. Without it a single matching record is
|
||||
# unrolled onto the pipeline and the caller assigns the dictionary itself, so
|
||||
# .Count reports the key count and [0] indexes a field rather than a record -
|
||||
# which fails as a confusing type mismatch rather than as a missing record.
|
||||
if ($RecordType) { return , @($records | Where-Object { $_['recordType'] -eq $RecordType }) }
|
||||
|
||||
, @($records)
|
||||
}
|
||||
|
||||
function New-TestAuditContext {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds an audit context without needing a real configuration file.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Mode = 'Preview',
|
||||
[string] $RunId = '00000000-0000-0000-0000-00000000f001'
|
||||
)
|
||||
|
||||
New-PersonaAuditContext -RunId $RunId -EngineVersion '0.1.0' -Mode $Mode -Configuration ([pscustomobject]@{
|
||||
ConfigVersion = '1.0.0'
|
||||
ConfigurationHash = ('0' * 64)
|
||||
})
|
||||
}
|
||||
@@ -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