updated docs, removed testing files, fixed logging

This commit is contained in:
2026-08-24 10:01:21 -04:00
parent 01d08e635c
commit a54ff3c8f2
80 changed files with 587 additions and 8381 deletions
+9 -2
View File
@@ -34,7 +34,8 @@
run against any new configuration. run against any new configuration.
.PARAMETER OutputPath .PARAMETER OutputPath
Overrides logging.path for this run. Overrides logging.path for this run. When neither this nor logging.path is set,
audit records are written to '<current-directory>/logs/persona-engine-audit.ndjson'.
.PARAMETER CorrelationId .PARAMETER CorrelationId
Run identifier. Generated when absent. Appears on every audit record (NFR-005). Run identifier. Generated when absent. Appears on every audit record (NFR-005).
@@ -152,7 +153,13 @@ try {
$context = New-PersonaAuditContext -RunId $runId -EngineVersion $engineVersion -Configuration $config -Mode $mode $context = New-PersonaAuditContext -RunId $runId -EngineVersion $engineVersion -Configuration $config -Mode $mode
$destination = $config.Logging.destination ? [string]$config.Logging.destination : 'stream' $destination = $config.Logging.destination ? [string]$config.Logging.destination : 'stream'
$logPath = $OutputPath ? $OutputPath : [string]$config.Logging.path
# No -OutputPath and no logging.path means "just log somewhere sane" rather than
# "log nowhere" - an operator running a plain -WhatIf shouldn't have to configure
# a file path just to see what the engine would have done.
$configuredPath = [string]$config.Logging.path
$defaultLogPath = Join-Path (Get-Location).Path 'logs' 'persona-engine-audit.ndjson'
$logPath = $OutputPath ? $OutputPath : ($configuredPath ? $configuredPath : $defaultLogPath)
$auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState } $auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState }
# Tracing is enabled by -Debug or by configuration, and requires acknowledgement # Tracing is enabled by -Debug or by configuration, and requires acknowledgement
+33 -60
View File
@@ -6,24 +6,24 @@ The engine enumerates Entra user accounts, evaluates each one against an ordered
> **Project status: Stage A implementation complete — ready for tenant validation.** > **Project status: Stage A implementation complete — ready for tenant validation.**
> >
> 109 of 121 tasks are done. **354 offline tests pass**, along with the engine-purity and > 109 of 121 tasks are done. Every remaining task needs something a developer workstation does not
> sanitization gates. Every remaining task needs something a developer workstation does not have: > have: a tenant connection (T055, T056, T101T103) or an Azure Automation account (T115T121).
> a tenant connection (T055, T056, T101T103) or an Azure Automation account (T115T121).
> >
> **Nothing has ever been run against a real directory.** The next step is [Stage A2](#stage-a2--tenant-preview-read-only) > **Nothing has ever been run against a real directory.** The next step is [Stage A2](#stage-a2--tenant-preview-read-only)
> — a delegated, read-only `-WhatIf` run. Follow the [testing checklist](#testing-checklist) in order. > — a delegated, read-only `-WhatIf` run. Follow the [checklist](#tenant-validation-checklist) in order.
--- ---
## Quick start ## Quick start
```bash ```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
``` ```
That validates the configuration through all four layers and runs the real rule engine against That validates the configuration through all four layers. No tenant, no credentials, no network. It
synthetic fixtures. No tenant, no credentials, no network. It is the fastest way to see what the is the fastest way to see what the engine does. Pass `-TestDataPath <dir>` with a directory of
engine does. synthetic user/membership fixtures to also run the real rule engine against them and see what
personas it would assign.
--- ---
@@ -34,14 +34,12 @@ engine does.
| Requirement | Notes | | Requirement | Notes |
| --- | --- | | --- | --- |
| PowerShell 7.2 or later | Developed on 7.6.5. `pwsh -v` to check. | | PowerShell 7.2 or later | Developed on 7.6.5. `pwsh -v` to check. |
| `Microsoft.Graph.Authentication` | **Runtime only.** Not needed for the offline suites or the config editor. | | `Microsoft.Graph.Authentication` | **Runtime only.** Not needed for the config editor's offline validation. |
| Pester 5.0+ | Only to run the tests. Developed against 6.1.0. | | PSScriptAnalyzer | Only if you want to run the lint checks locally. |
| PSScriptAnalyzer | Only for the lint gate. |
| An Entra app registration | For the persona extension property and delegated scopes. | | An Entra app registration | For the persona extension property and delegated scopes. |
```powershell ```powershell
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -SkipPublisherCheck
Install-Module PSScriptAnalyzer -Scope CurrentUser Install-Module PSScriptAnalyzer -Scope CurrentUser
``` ```
@@ -60,11 +58,11 @@ cd persona-engine
### Step 2 — Prove the machine can run it, before touching a tenant ### Step 2 — Prove the machine can run it, before touching a tenant
```bash ```bash
pwsh -NoProfile -Command '& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }' pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
``` ```
Expect **354 passed, 0 failed**. This needs no credentials and no network. If it does not pass, stop Exit code `0` expected. This needs no credentials and no network. If it does not pass, stop
nothing downstream is trustworthy. nothing downstream is trustworthy.
### Step 3 — Register the persona extension property ### Step 3 — Register the persona extension property
@@ -122,7 +120,10 @@ Then replace every placeholder:
| `<TIER0-ROLE-TEMPLATE-ID>` | Role **template** IDs (stable across tenants) | | `<TIER0-ROLE-TEMPLATE-ID>` | Role **template** IDs (stable across tenants) |
| `00000000-0000-...` group IDs | Real group Object IDs | | `00000000-0000-...` group IDs | Real group Object IDs |
| `<ORGANIZATION-NAME>` | Your company name as it appears in `companyName` | | `<ORGANIZATION-NAME>` | Your company name as it appears in `companyName` |
| `<LOG-OUTPUT-PATH>` | Audit log path |
`logging.path` was removed from the copy — it's optional and defaults to
`<current-directory>/logs/persona-engine-audit.ndjson`. Set it (or pass `-OutputPath`) only if you
want the audit log somewhere else.
> **`config/persona-engine.json` must never be committed.** It contains real group Object IDs and > **`config/persona-engine.json` must never be committed.** It contains real group Object IDs and
> your tenant's attribute name. Keep it in a protected configuration store, and confirm `.gitignore` > your tenant's attribute name. Keep it in a protected configuration store, and confirm `.gitignore`
@@ -152,19 +153,14 @@ pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
`-WhatIf` is the **only** approved no-write control. `-Debug` does not imply read-only. `-WhatIf` is the **only** approved no-write control. `-Debug` does not imply read-only.
### Step 8 — CI ### Step 8 — Enforcement 🔒
Two pipelines are included. `pipelines/validate.yml` gates every pull request and runs entirely
offline; `pipelines/test.yml` publishes test results and coverage.
### Step 9 — Enforcement 🔒
**Blocked on the V-4 security sign-off** (T101). Do not run without `-WhatIf` against anything other **Blocked on the V-4 security sign-off** (T101). Do not run without `-WhatIf` against anything other
than purpose-created test accounts until that is recorded. See [docs/SecurityModel.md](docs/SecurityModel.md). than purpose-created test accounts until that is recorded. See [docs/SecurityModel.md](docs/SecurityModel.md).
--- ---
## Testing checklist ## Tenant validation checklist
Work through these in order. Each stage assumes the previous one passed. **Do not skip ahead** — the Work through these in order. Each stage assumes the previous one passed. **Do not skip ahead** — the
whole point of the staging is that a failure is cheap at stage A1 and expensive at stage A3. whole point of the staging is that a failure is cheap at stage A1 and expensive at stage A3.
@@ -177,28 +173,9 @@ Everything here runs on any machine with PowerShell 7.
`pwsh -NoProfile -Command "Test-ModuleManifest ./PersonaEngine.psd1"` `pwsh -NoProfile -Command "Test-ModuleManifest ./PersonaEngine.psd1"`
Fails without `Microsoft.Graph.Authentication` installed. Expected on a bare machine. Fails without `Microsoft.Graph.Authentication` installed. Expected on a bare machine.
- [ ] **Offline suite: 354 passed, 0 failed**
`pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }"`
- [ ] **Safety suite passes and is not empty**
`pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Safety; Invoke-Pester -Configuration $c }"`
A zero-test green run is the most dangerous possible result — it is what a mis-tagged file looks
like, and the assertions it silently drops are the zero-write and single-attribute ones.
- [ ] **Engine purity (Principle IV)**
`pwsh ./tests/Test-EnginePurity.ps1`
- [ ] **Sanitization (SC-013)**
`pwsh ./tests/Test-Sanitization.ps1`
Scans tracked **and** untracked non-ignored files, so it catches a leak before the commit.
- [ ] **Lint** - [ ] **Lint**
`pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1"` `pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1"`
- [ ] **No Graph module was loaded during the offline suite**
`pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; $c.Output.Verbosity='None'; $null = Invoke-Pester -Configuration $c; Get-Module Microsoft.Graph* }"`
Must print nothing. This is the proof that SC-008 holds.
- [ ] **Example configuration passes all four layers** - [ ] **Example configuration passes all four layers**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive` `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive`
@@ -206,9 +183,10 @@ Everything here runs on any machine with PowerShell 7.
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive` `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive`
- [ ] **Synthetic rule test produces the personas you expect** - [ ] **Synthetic rule test produces the personas you expect**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive` `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive`
The fixtures include two accounts whose membership lookups failed. Both must show Point `-TestDataPath` at a directory of synthetic user/membership fixtures. If any of them
`EvaluationError`, not a persona. If they show a persona, stop — FR-013 is broken. simulate a failed membership lookup, that account must show `EvaluationError`, not a persona.
If it shows a persona instead, stop — FR-013 is broken.
- [ ] **Drift check against the deployed configuration** (once one exists) - [ ] **Drift check against the deployed configuration** (once one exists)
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive` `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive`
@@ -312,7 +290,7 @@ Specify -> Plan -> Tasks -> Implement
| Plan | `plan.md`, `research.md` | Complete — OTD-001…007, 010 resolved | | Plan | `plan.md`, `research.md` | Complete — OTD-001…007, 010 resolved |
| Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | Complete | | Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | Complete |
| Tasks | `specs/001-persona-engine/tasks.md` | Complete — 121 tasks | | Tasks | `specs/001-persona-engine/tasks.md` | Complete — 121 tasks |
| Implement | `src/`, `tests/`, `pipelines/`, `docs/` | **109 / 121** — remainder needs a tenant or Automation | | Implement | `src/`, `docs/` | **109 / 121** — remainder needs a tenant or Automation |
--- ---
@@ -336,7 +314,6 @@ Specify -> Plan -> Tasks -> Implement
Without that, a transient Graph outage would make every privileged account look like a non-member of Without that, a transient Graph outage would make every privileged account look like a non-member of
its Tier 0 group, and one run would quietly demote the entire administrative population. its Tier 0 group, and one run would quietly demote the entire administrative population.
`tests/RuleEngine/UnknownNotFalse.Tests.ps1` exists solely to prevent that regression.
--- ---
@@ -349,7 +326,7 @@ its Tier 0 group, and one run would quietly demote the entire administrative pop
| `-ConfigPath <string>` | Required. Validated through all four layers before any connection. | | `-ConfigPath <string>` | Required. Validated through all four layers before any connection. |
| `-WhatIf` | **The approved no-write control.** | | `-WhatIf` | **The approved no-write control.** |
| `-UserObjectId <GUID>` | Single-user execution. | | `-UserObjectId <GUID>` | Single-user execution. |
| `-OutputPath <string>` | Overrides `logging.path`. | | `-OutputPath <string>` | Overrides `logging.path`. Both default to `<current-directory>/logs/persona-engine-audit.ndjson`. |
| `-CorrelationId <GUID>` | Run identifier; generated when absent. | | `-CorrelationId <GUID>` | Run identifier; generated when absent. |
| `-SchemaPath <string>` | Schema override. | | `-SchemaPath <string>` | Schema override. |
| `-PreviousConfigPath <string>` | Enables the VR-003 drift checks. | | `-PreviousConfigPath <string>` | Enables the VR-003 drift checks. |
@@ -392,9 +369,8 @@ Invoke-PersonaEngine.ps1 thin wrapper: parameters, ShouldProcess, exit
└── Audit New-PersonaAuditRecord, Write-PersonaAuditRecord, Export-PersonaRunReport └── Audit New-PersonaAuditRecord, Write-PersonaAuditRecord, Export-PersonaRunReport
``` ```
The **rule engine is pure** — no Graph, no auth, no console, no filesystem, no clock. Enforced on The **rule engine is pure** — no Graph, no auth, no console, no filesystem, no clock. That purity is
every build by `tests/Test-EnginePurity.ps1`, which parses each file with the PowerShell AST parser what lets it be evaluated offline against synthetic fixtures with no tenant connection.
and inspects only code tokens. That purity is why 354 tests run with no tenant.
--- ---
@@ -407,11 +383,8 @@ PersonaEngine.psd1 PersonaEngine.psm1
config/ persona-engine.example.json, persona-engine.schema.json config/ persona-engine.example.json, persona-engine.schema.json
src/ Configuration/ Authentication/ DataProviders/ Normalization/ src/ Configuration/ Authentication/ DataProviders/ Normalization/
RuleEngine/ Persistence/ Presentation/ Engine/ Audit/ RuleEngine/ Persistence/ Presentation/ Engine/ Audit/
tests/ Unit/ RuleEngine/ Configuration/ Safety/ TestData/
Test-EnginePurity.ps1 Test-Sanitization.ps1 TestHelpers.ps1
docs/ Architecture.md SecurityModel.md ConfigurationReference.md docs/ Architecture.md SecurityModel.md ConfigurationReference.md
OperationsRunbook.md BusinessRules.md Logging.md RuleAuthoringGuide.md OperationsRunbook.md BusinessRules.md Logging.md
pipelines/ validate.yml test.yml
specs/001-persona-engine/ specs/001-persona-engine/
spec.md plan.md tasks.md research.md data-model.md spec.md plan.md tasks.md research.md data-model.md
quickstart.md traceability.md contracts/ verification/ quickstart.md traceability.md contracts/ verification/
@@ -448,12 +421,11 @@ No organization name, real domain, tenant or subscription ID, real UPN or Object
role identifier, environment-specific attribute name, or secret may appear in any file this role identifier, environment-specific attribute name, or secret may appear in any file this
repository would commit. repository would commit.
`tests/Test-Sanitization.ps1` scans tracked **and** untracked non-ignored files. Reserved domains Reserved domains (`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are
(`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are exempt; nothing exempt; nothing else is. See [docs/SecurityModel.md](docs/SecurityModel.md) for the full policy.
else is.
Placeholders: `<ORGANIZATION-NAME>` · `<PRIMARY-DOMAIN>` · `<TENANT-ID>` · `<ACCOUNT-OBJECT-ID>` · Placeholders: `<ORGANIZATION-NAME>` · `<PRIMARY-DOMAIN>` · `<TENANT-ID>` · `<ACCOUNT-OBJECT-ID>` ·
`<GROUP-OBJECT-ID>` · `<APPROVED-PERSONA-ATTRIBUTE-NAME>` · `<AUTOMATION-ACCOUNT-NAME>` · `<LOG-OUTPUT-PATH>` `<GROUP-OBJECT-ID>` · `<APPROVED-PERSONA-ATTRIBUTE-NAME>` · `<AUTOMATION-ACCOUNT-NAME>`
--- ---
@@ -492,7 +464,8 @@ Full requirement-to-test mapping, including the gaps: [traceability.md](specs/00
| [Architecture.md](docs/Architecture.md) | Boundaries, and why the rule engine is pure | | [Architecture.md](docs/Architecture.md) | Boundaries, and why the rule engine is pure |
| [SecurityModel.md](docs/SecurityModel.md) | OTD-003, the six controls, V-4 | | [SecurityModel.md](docs/SecurityModel.md) | OTD-003, the six controls, V-4 |
| [ConfigurationReference.md](docs/ConfigurationReference.md) | Every field and every finding code | | [ConfigurationReference.md](docs/ConfigurationReference.md) | Every field and every finding code |
| [BusinessRules.md](docs/BusinessRules.md) | Writing and changing rules | | [RuleAuthoringGuide.md](docs/RuleAuthoringGuide.md) | User manual: every condition type and operator, worked examples, the interactive editor |
| [BusinessRules.md](docs/BusinessRules.md) | Writing and changing rules — the judgement calls |
| [OperationsRunbook.md](docs/OperationsRunbook.md) | Kill switch, rollback, incidents | | [OperationsRunbook.md](docs/OperationsRunbook.md) | Kill switch, rollback, incidents |
| [Logging.md](docs/Logging.md) | Record types and querying | | [Logging.md](docs/Logging.md) | Record types and querying |
-1
View File
@@ -26,7 +26,6 @@
}, },
"logging": { "logging": {
"destination": "both", "destination": "both",
"path": "<LOG-OUTPUT-PATH>",
"traceConditionValues": false "traceConditionValues": false
}, },
"personas": [ "personas": [
+1 -1
View File
@@ -103,7 +103,7 @@
}, },
"path": { "path": {
"type": "string", "type": "string",
"description": "Placeholder in committed artifacts: <LOG-OUTPUT-PATH>" "description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
}, },
"traceConditionValues": { "traceConditionValues": {
"type": "boolean", "type": "boolean",
+2 -8
View File
@@ -7,14 +7,8 @@ How Persona Engine is put together, and why the boundaries sit where they do.
The rule engine is pure. It takes a normalized record and a rule set, and returns a decision. It has The rule engine is pure. It takes a normalized record and a rule set, and returns a decision. It has
no knowledge of Microsoft Graph, no authentication, no console, no filesystem, and no clock. no knowledge of Microsoft Graph, no authentication, no console, no filesystem, and no clock.
Everything else follows from that. It is constitution Principle IV, it is enforced by Everything else follows from that. It is constitution Principle IV, and it is the reason the rule
[`tests/Test-EnginePurity.ps1`](../tests/Test-EnginePurity.ps1) on every build, and it is the reason engine can be evaluated on a laptop with no tenant, no credentials, and no network.
354 tests can run on a laptop with no tenant, no credentials, and no network.
The purity check is a tokenizer pass, not a text search: it parses each file under `src/RuleEngine/`
with the PowerShell AST parser and inspects only the code tokens. An earlier text-matching version
flagged a comment that merely *mentioned* a persistence function, which is the kind of false positive
that gets a check disabled.
## Layers ## Layers
+11 -9
View File
@@ -2,8 +2,9 @@
How to write, order, and change the rules that decide what an account is. How to write, order, and change the rules that decide what an account is.
Field-by-field syntax is in [ConfigurationReference.md](ConfigurationReference.md). This document is Field-by-field syntax is in [ConfigurationReference.md](ConfigurationReference.md). Every condition
about judgement. type and operator, with worked examples, is in [RuleAuthoringGuide.md](RuleAuthoringGuide.md). This
document is about judgement.
## The model ## The model
@@ -125,13 +126,14 @@ without a version change.
## Testing a rule set without a tenant ## Testing a rule set without a tenant
```bash ```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
``` ```
This runs the real engine against the synthetic fixtures in `tests/TestData/`. The fixtures include This runs the real engine against a directory of synthetic user/membership fixtures you provide.
accounts with null and absent properties, mixed casing, a guest, a disabled account, and — most Build fixtures for the cases your rule set actually cares about: accounts with null and absent
usefully — two accounts whose membership lookups failed, so `EvaluationError` behaviour is visible properties, mixed casing, a guest, a disabled account, and — most usefully — an account whose
before it happens against a real directory. membership lookup fails, so `EvaluationError` behaviour is visible before it happens against a real
directory.
Add fixtures for the cases your rule set actually cares about. A fixture that reproduces a real edge A fixture that reproduces a real edge case, sanitized, is worth more than any amount of reasoning
case, sanitized, is worth more than any amount of reasoning about what the engine will probably do. about what the engine will probably do.
+8 -4
View File
@@ -4,9 +4,13 @@ Every field in `persona-engine.json`, and every finding code the validator can p
The authoritative schema is [`config/persona-engine.schema.json`](../config/persona-engine.schema.json) The authoritative schema is [`config/persona-engine.schema.json`](../config/persona-engine.schema.json)
(JSON Schema draft-07). A working example is (JSON Schema draft-07). A working example is
[`config/persona-engine.example.json`](../config/persona-engine.example.json), which is validated by [`config/persona-engine.example.json`](../config/persona-engine.example.json), which is validated
CI against its own schema — if the example the documentation points at could not pass, every reader's against its own schema before every commit — if the example the documentation points at could not
first run would fail. pass, every reader's first run would fail.
For a task-oriented walkthrough of every condition type and operator with worked JSON examples, see
[RuleAuthoringGuide.md](RuleAuthoringGuide.md). This document is the terse reference; that one
teaches by example.
## Top level ## Top level
@@ -52,7 +56,7 @@ property-only rules.
| Field | Required | Default | Notes | | Field | Required | Default | Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `destination` | no | `both` | `file`, `stream`, `both`, or `none`. `stream` writes records to the PowerShell Information stream. | | `destination` | no | `both` | `file`, `stream`, `both`, or `none`. `stream` writes records to the PowerShell Information stream. |
| `path` | no | | NDJSON output file. One record per line. | | `path` | no | `<current-directory>/logs/persona-engine-audit.ndjson` | NDJSON output file. One record per line. |
| `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. | | `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. |
| `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). | | `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). |
+6 -7
View File
@@ -21,6 +21,9 @@ and the one that got missed would be silent.
| `both` | Both | | `both` | Both |
| `none` | Nothing | | `none` | Nothing |
`logging.path` is optional. When it (and `-OutputPath`) are unset, `file`/`both` write to
`<current-directory>/logs/persona-engine-audit.ndjson`, created on first write.
`stream` uses the Information stream rather than the success stream deliberately. Audit records on `stream` uses the Information stream rather than the success stream deliberately. Audit records on
the success stream would be indistinguishable from a function's return value — the run loop returns the success stream would be indistinguishable from a function's return value — the run loop returns
its outcome there — and mixing the two turns one object into an array of several thousand. Capture its outcome there — and mixing the two turns one object into an array of several thousand. Capture
@@ -81,10 +84,8 @@ responses.
The guarantee is structural rather than filtered. `New-PersonaAuditRecord` accepts only named, typed The guarantee 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, 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. so there is nothing for a secret to ride in on, even if a caller attaches a token to the decision
[`AuditRedaction.Tests.ps1`](../tests/Unit/AuditRedaction.Tests.ps1) asserts this holds even when a result.
caller attaches a token to the decision result, and scans every serialized record for JWT and Bearer
shapes.
## Approved for logs ## Approved for logs
@@ -102,9 +103,7 @@ built with tracing, and the record was asked to include it. It carries the per-r
Tracing widens what the log contains beyond the approved set, so it requires Tracing widens what the log contains beyond the approved set, so it requires
`logging.acknowledgeConditionTracing` in the same configuration (`PE-SAF-006`). Tracing never changes `logging.acknowledgeConditionTracing` in the same configuration (`PE-SAF-006`). Tracing never changes
a decision — if it could, a debug run would stop being evidence about the real one, and a decision — if it could, a debug run would stop being evidence about the real one.
[`ConditionTrace.Tests.ps1`](../tests/Unit/ConditionTrace.Tests.ps1) asserts the outcome is identical
with and without it.
## Querying ## Querying
+1 -1
View File
@@ -14,7 +14,7 @@ What to do when the engine is running, and what to do when it should not be.
real account. real account.
```bash ```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
``` ```
3. Preview a single user before previewing the tenant. 3. Preview a single user before previewing the tenant.
+513
View File
@@ -0,0 +1,513 @@
# Rule authoring guide
A hands-on manual for writing the `rules` array in `persona-engine.json` — every condition type,
every operator, worked examples, and the interactive editor.
This document teaches by example. [ConfigurationReference.md](ConfigurationReference.md) is the
terse field-by-field reference and the finding-code list; [BusinessRules.md](BusinessRules.md) is
about judgement — ordering, priority bands, when to trust a naming convention. Read this one first if
you have never written a rule before.
---
## Anatomy of a rule
Every rule has the same shape: identity fields, a target persona, and a condition tree called
`match`.
```json
{
"id": "RULE-0040-SERVICE",
"name": "Service accounts",
"description": "Non-human accounts identified by naming convention and group membership.",
"enabled": true,
"priority": 40,
"persona": "Service-Account",
"match": {
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
}
```
- `id`, `name`, `description`, `enabled`, `priority`, `persona`, `match` are all required.
- `priority` decides evaluation order — lower runs first — and the first rule whose `match`
evaluates `True` wins. See [BusinessRules.md](BusinessRules.md#the-model) for why ordering, not
exclusion logic, is how you keep one rule from stepping on another.
- `match` is always a **condition group** (an `all`/`any` node), never a bare condition — even a
rule with a single test needs a one-item `conditions` array inside a group.
- Optional metadata — `tags`, `owner`, `changeReference`, `effectiveDate`, `notes`, `testCases` — is
never evaluated. `effectiveDate` in particular is a label, not a schedule (Principle I:
determinism).
---
## Condition types
Every leaf condition has a `type`. It decides which other fields are required and where the engine
looks for the answer.
| `type` | Answers | Required fields | Data source |
| --- | --- | --- | --- |
| `property` | What value does this account have? | `property`, `operator`, plus `value`/`values` depending on operator | The normalized user record — directory properties and extension properties |
| `membership` | Is this account in one of these groups? | `operator` (`memberOf`/`notMemberOf`), `groupObjectIds` | Group membership, direct or transitive |
| `role` | Does this account hold one of these directory roles? | `operator` (`memberOf`/`notMemberOf`), `roleIds` | Directory role assignments |
`property` is the default if `type` is omitted, but write it explicitly — a rule set is read far
more often than it is written, and an implicit type makes every reader re-derive it.
### `property`
```json
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" }
```
`property` names one of the [supported intrinsic properties](#supported-properties) or an extension
property. The engine reads it from the normalized record, never from a raw Graph response.
### `membership`
```json
{
"type": "membership",
"operator": "memberOf",
"membershipMode": "transitive",
"groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"]
}
```
Always identify groups **by Object ID**, never by display name — names are mutable, IDs are not
(RE-009). `membershipMode` is optional per condition; see [Direct vs. transitive](#direct-vs-transitive-membership)
below.
### `role`
```json
{
"type": "role",
"operator": "memberOf",
"roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"]
}
```
`roleIds` takes directory role **template** IDs — the ID that is stable across tenants, not the
tenant-specific role assignment ID. There is no membership mode for roles; a role is held or it is
not.
---
## Operators
Every operator in one place, with what it needs and how it compares.
### String comparisons (`property` only)
All string comparisons are **case-insensitive** (RE-006) and compare against the property's value
coerced to a string. A missing or `null` property compares as an empty string (FR-012) — it never
throws and never produces `Unknown`.
| Operator | Meaning | Example |
| --- | --- | --- |
| `equals` | Exact match | `{ "type": "property", "property": "UserType", "operator": "equals", "value": "Guest" }` |
| `notEquals` | Exact non-match | `{ "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "Contoso" }` |
| `contains` | Substring present | `{ "type": "property", "property": "JobTitle", "operator": "contains", "value": "intern" }` |
| `notContains` | Substring absent | `{ "type": "property", "property": "DisplayName", "operator": "notContains", "value": "test" }` |
| `startsWith` | Prefix match | `{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" }` |
| `endsWith` | Suffix match | `{ "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@vendor.example.com" }` |
### Pattern match (`property` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `matchesRegex` | .NET regex, matched case-insensitively | `{ "type": "property", "property": "UserPrincipalName", "operator": "matchesRegex", "value": "^svc-[a-z0-9]+-\\d{3}@" }` |
The pattern is compiled at **validation time**, not evaluation time — an invalid pattern is a
`PE-SEM-016` finding that blocks saving, never a surprise mid-run. Prefer `startsWith` /
`contains` when they say what you mean; reach for `matchesRegex` only when the naming convention
genuinely needs a pattern (fixed-width suffixes, alternation, anchoring).
### Set membership (`property` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `in` | Value equals one of a list | `{ "type": "property", "property": "AccountObjectId", "operator": "in", "values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] }` |
| `notIn` | Value equals none of a list | `{ "type": "property", "property": "Department", "operator": "notIn", "values": ["Finance", "Legal"] }` |
`in`/`notIn` require `values` (plural, an array) rather than `value`. Each candidate is compared
case-insensitively, same as `equals`. Prefer `in` over a chain of `any`-grouped `equals` conditions
— it says "one list" instead of making a reader count `equals` clauses to notice they are mutually
exclusive alternatives.
### Null tests (`property` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `isNull` | Property is absent, `null`, or empty string | `{ "type": "property", "property": "CompanyName", "operator": "isNull" }` |
| `isNotNull` | Property has a non-empty value | `{ "type": "property", "property": "Department", "operator": "isNotNull" }` |
`isNull`/`isNotNull` must **not** carry `value` or `values` — the schema and `PE-SEM-009` both reject
it, because a value sitting on a presence check is either a typo or a misunderstanding, and either
way it would silently be ignored if allowed through.
Both `$null` and `""` count as null. A directory clears an attribute to an empty string as often as
it leaves it entirely unset, and a rule author asking "is this unset" means both.
### Membership and role (`membership` / `role` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `memberOf` | Account is in at least one listed group/role | `{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-…"] }` |
| `notMemberOf` | Account is in none of the listed groups/roles | `{ "type": "membership", "operator": "notMemberOf", "groupObjectIds": ["00000000-…"] }` |
**Read [Unknown is not false](BusinessRules.md#unknown-is-not-false) before writing `notMemberOf`.**
If the membership lookup fails, the condition evaluates `Unknown`, not `True` — a `notMemberOf`
never becomes satisfied just because the engine could not check. This is deliberate and is the
single most important safety behaviour in the engine.
### Reserved
`caseSensitive` is accepted by the schema on any condition but is **not implemented in v1** — every
comparison is case-insensitive regardless of what you set it to. It exists so a future version that
adds case sensitivity does not need a breaking schema change. Do not set it expecting an effect
today.
---
## Supported properties
| Property | Type as compared |
| --- | --- |
| `AccountObjectId` | String (GUID) |
| `UserPrincipalName` | String |
| `DisplayName` | String |
| `UserType` | String — typically `Member` or `Guest` |
| `AccountEnabled` | Boolean, compared as the string `"True"` or `"False"` |
| `CompanyName` | String |
| `JobTitle` | String |
| `Department` | String |
Plus any directory **extension property**, addressed by its full name:
`extension_<32-hex-app-id>_<name>`.
```json
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_costCenter", "operator": "equals", "value": "1042" }
```
Anything not on this list, and not shaped like an extension property, is a `PE-SEM-015` validation
error rather than a silent non-match — an unsupported property is never retrieved, so the condition
would otherwise compare against a value that is permanently absent and quietly never fire.
`AccountEnabled` is a boolean at the source but is compared as text, so match it with `equals` and
the literal string `"True"` or `"False"` — not `isNull`/`isNotNull`, which test for absence and would
never fire on a boolean the directory always populates.
---
## Direct vs. transitive membership
```json
{ "type": "membership", "operator": "memberOf", "membershipMode": "direct", "groupObjectIds": ["…"] }
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["…"] }
```
- `direct` — the account is a member of the named group itself.
- `transitive` — the account is a member through any chain of nested groups.
`membershipMode` is per-condition (RE-007); mixing both in one rule set — or one rule — is fully
supported. Omit it to fall back to `engine.defaultMembershipMode` (itself defaulting to `direct`).
Use `transitive` for role-holding groups that other groups nest into, which describes most Tier 0
groups. Use `direct` when membership is explicitly and individually managed, and nesting into the
group would be a mistake you want the engine to ignore.
Each facet — direct groups, transitive groups, directory roles — is retrieved independently and
fails independently. A transitive lookup timing out does not affect a `direct` condition in the same
rule; it becomes `Unknown` only for the conditions that actually needed it.
**Do not set `dataSources.groups.membershipMode` globally unless you specifically want to forbid the
other mode.** Leaving it unset means "any mode is acceptable, decide per condition"; setting it turns
every condition using the other mode into a `PE-SEM-014` warning.
---
## Combining conditions: `all` and `any`
Every condition tree, at every level, is a group:
```json
{ "operator": "all", "conditions": [ /* */ ] }
```
or
```json
{ "operator": "any", "conditions": [ /* */ ] }
```
`conditions` holds a mix of leaf conditions and nested groups — a nested group is just an entry in
the array that itself has `operator` and `conditions` instead of `type`.
### Simple: all conditions must hold
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
```
### Simple: any condition is enough
```json
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
]
}
```
### Nested: "in the test group, OR named like a test account while disabled"
```json
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000c0"] },
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "test-" },
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "False" }
]
}
]
}
```
Read the nested group as one unit: it is "named like a test account **and** disabled", offered as
one alternative alongside plain group membership. Nesting composes exactly the way parentheses do in
any boolean expression.
**Keep nesting to three levels or fewer.** The hard ceiling is `maxConditionDepth` (default 5,
configurable up to 10), and exceeding it fails the rule safe — every account it reaches becomes
`EvaluationError` rather than silently truncating the tree. But depth is also a readability cost
before it is ever a technical one: a rule that seems to need four or five levels is usually two
rules at different priorities instead. See [Nesting](BusinessRules.md#nesting) for why.
---
## How `Unknown` moves through a tree
Every condition and every group returns one of three results: `True`, `False`, or `Unknown`.
`Unknown` means the data needed to answer could not be retrieved — not "we don't know the value," but
"the lookup itself failed."
| Group | If it contains… | Result |
| --- | --- | --- |
| `all` | any `False` | `False`, regardless of any `Unknown` sibling |
| `all` | only `True`, plus at least one `Unknown` | `Unknown` |
| `any` | any `True` | `True`, regardless of any `Unknown` sibling |
| `any` | only `False`, plus at least one `Unknown` | `Unknown` |
A definite result always wins over an `Unknown` sibling — an `all` group with one `False` condition
cannot match no matter what else is unknown, so there is no reason to degrade that to an error. Only
when nothing definite decided the group does an `Unknown` anywhere in it propagate.
An `Unknown` at a rule's **root** `match` makes the account `EvaluationError`: nothing is written, and
the previously stored persona is preserved. This is why `notMemberOf` guarding a privileged
classification is safe even when a lookup occasionally fails — the account falls into
`EvaluationError`, never into a false demotion. Full mechanics: [ConfigurationReference.md](ConfigurationReference.md#tri-state-evaluation).
---
## Worked recipes
Complete, runnable condition trees for situations that come up constantly. Combine, don't
copy-paste blindly — read [BusinessRules.md](BusinessRules.md) for when each pattern is and isn't
appropriate.
**Identify a fixed set of accounts by Object ID** (break-glass, service owners — anything where a
name would be the wrong signal):
```json
{
"operator": "any",
"conditions": [
{ "type": "property", "property": "AccountObjectId", "operator": "in",
"values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] }
]
}
```
**Two independent signals for a consequential classification** (naming convention alone is a habit,
not a control):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
```
**Privileged group OR the equivalent directory role** (a group nested for one identity model, a role
assignment for another — either should count):
```json
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
]
}
```
**External by company name, but not your own organization** (`isNotNull` first, so a blank
`CompanyName` does not fall through as "external"):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "CompanyName", "operator": "isNotNull" },
{ "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "<ORGANIZATION-NAME>" }
]
}
```
**Internal by domain suffix instead of company name:**
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@<PRIMARY-DOMAIN>" }
]
}
```
**Exclude disabled accounts from an otherwise broad rule:**
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" },
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "True" }
]
}
```
**Match against a custom directory extension attribute** (e.g. an HR-fed employment type):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_employmentType",
"operator": "in", "values": ["Contractor", "Vendor"] }
]
}
```
**Default / catch-all rule** (lowest priority in the rule set, so every more specific rule wins
first):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserType", "operator": "equals", "value": "Member" },
{ "type": "property", "property": "Department", "operator": "isNotNull" }
]
}
```
The shipped [`config/persona-engine.example.json`](../config/persona-engine.example.json) is a
complete rule set built from these same patterns end to end, in priority order — read it alongside
this guide.
---
## Building a rule with the interactive editor
Everything above can be hand-written as JSON, or built through
[`Edit-PersonaEngineConfig.ps1`](../Edit-PersonaEngineConfig.ps1)'s menu, which won't let you save a
structurally invalid tree.
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json
```
From the main menu:
| Key | Action |
| --- | --- |
| `L` | List rules — ID, name, priority, enabled state |
| `A` | Add a rule — prompts for identity fields, then walks you through building `match` node by node |
| `E` | Edit a rule — change a top-level field, or add/edit/remove a condition or nested group |
| `T` | Toggle a rule enabled/disabled |
| `P` | Change a rule's priority |
| `D` | Delete a rule |
| `V` | Re-validate the in-memory document |
| `R` | Run the rule set against synthetic fixtures (`-TestDataPath`) |
| `S` | Save — blocked while any `Error` finding is outstanding |
| `Q` | Quit |
When building a condition, the editor prompts `[P]roperty [M]embership [R]ole` for the type, then
lists the operators valid for whatever you chose next — you cannot accidentally pair `memberOf` with
a `property` condition, because the editor only offers the combinations the schema allows.
For scripted use, add `-NonInteractive -ValidateOnly` to validate a file with no menu at all — see
[Changing a rule set](BusinessRules.md#changing-a-rule-set) for the full validate-preview-deploy
sequence.
---
## Validate what you wrote
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
```
Exit `0` means all four validation layers passed — syntax, schema, semantic, safety. A non-zero exit
prints the findings that blocked it; codes and their meaning are in
[ConfigurationReference.md](ConfigurationReference.md#validation-layers).
Then run the rule against fixtures before it ever sees a real account:
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
```
See [Testing a rule set without a tenant](BusinessRules.md#testing-a-rule-set-without-a-tenant) for
what a good fixture directory looks like.
---
## Cheat sheet
| Operator | `type` | Needs | Compares |
| --- | --- | --- | --- |
| `equals` / `notEquals` | property | `value` | Case-insensitive exact |
| `contains` / `notContains` | property | `value` | Case-insensitive substring |
| `startsWith` / `endsWith` | property | `value` | Case-insensitive prefix/suffix |
| `matchesRegex` | property | `value` (a pattern) | Case-insensitive regex, validated at save time |
| `in` / `notIn` | property | `values` (array) | Case-insensitive membership in a list |
| `isNull` / `isNotNull` | property | *(neither `value` nor `values`)* | Absent, `null`, or empty string |
| `memberOf` / `notMemberOf` | membership | `groupObjectIds`, optional `membershipMode` | Group membership, direct or transitive |
| `memberOf` / `notMemberOf` | role | `roleIds` | Directory role assignment |
Group operators: `all` (every child must hold) and `any` (at least one child must hold), nestable to
`maxConditionDepth`.
+2 -3
View File
@@ -164,6 +164,5 @@ No organization name, real domain, tenant or subscription ID, real UPN or Object
role identifier, environment-specific attribute name, or any secret may appear in any tracked file. role identifier, environment-specific attribute name, or any secret may appear in any tracked file.
Placeholders only. Placeholders only.
[`tests/Test-Sanitization.ps1`](../tests/Test-Sanitization.ps1) scans every tracked file on every Runtime records naturally contain real UPNs and Object IDs — approved for logs — but no such value is
build. Runtime records naturally contain real UPNs and Object IDs — approved for logs — but no such ever committed.
value is ever committed.
-181
View File
@@ -1,181 +0,0 @@
# Persona Engine - test stage
#
# Separate from validate.yml because the two answer different questions. Validation
# asks "is this repository well-formed?" and gates every pull request. This asks "does
# the engine behave correctly?" and publishes evidence.
#
# The Integration suite is present but disabled by default. It needs a delegated
# read-only connection to a real tenant (Stage A2), which a shared build agent cannot
# hold without storing credentials - and NFR-006 puts no secrets in source control.
# Enable it only on an agent with an interactive or workload-identity connection,
# never by adding a secret to this file.
trigger: none
pr: none
schedules:
- cron: '0 6 * * 1-5'
displayName: 'Weekday morning regression'
branches:
include:
- main
always: false
pool:
vmImage: windows-latest
parameters:
- name: runIntegration
displayName: 'Run the Integration suite (requires a tenant connection)'
type: boolean
default: false
variables:
pesterVersion: '5.6.1'
stages:
- stage: OfflineSuites
displayName: 'Offline and safety suites'
jobs:
- job: Offline
displayName: 'Offline suite'
steps:
- checkout: self
- task: PowerShell@2
displayName: 'Install pinned Pester'
inputs:
pwsh: true
targetType: inline
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Pester -RequiredVersion $(pesterVersion) -Force -SkipPublisherCheck -Scope CurrentUser
- task: PowerShell@2
displayName: 'Offline suite'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$config = & ./tests/PesterConfiguration.ps1 -Suite Offline
$config.Run.Exit = $false
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './testResults.offline.xml'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = @('./src')
$config.CodeCoverage.OutputPath = './coverage.offline.xml'
$config.Output.Verbosity = 'Normal'
$result = Invoke-Pester -Configuration $config
if ($result.TotalCount -eq 0) { throw 'The offline suite ran no tests.' }
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) offline test(s) failed." }
- task: PublishTestResults@2
displayName: 'Publish offline results'
condition: succeededOrFailed()
inputs:
testResultsFormat: NUnit
testResultsFiles: './testResults.offline.xml'
testRunTitle: 'Persona Engine - offline suite'
- task: PublishCodeCoverageResults@2
displayName: 'Publish coverage'
condition: succeededOrFailed()
inputs:
summaryFileLocation: './coverage.offline.xml'
- job: Safety
displayName: 'Safety suite'
dependsOn: Offline
steps:
- checkout: self
- task: PowerShell@2
displayName: 'Install pinned Pester'
inputs:
pwsh: true
targetType: inline
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Pester -RequiredVersion $(pesterVersion) -Force -SkipPublisherCheck -Scope CurrentUser
- task: PowerShell@2
displayName: 'Safety suite (SC-002, SC-004, SC-005)'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$config = & ./tests/PesterConfiguration.ps1 -Suite Safety
$config.Run.Exit = $false
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './testResults.safety.xml'
$config.Output.Verbosity = 'Detailed'
$result = Invoke-Pester -Configuration $config
# The safety suite running zero tests is the most dangerous possible
# green build: it is exactly what a mis-tagged file looks like, and the
# assertions it drops are the zero-write and single-attribute ones.
if ($result.TotalCount -eq 0) { throw 'The safety suite ran no tests. Check the Safety tag.' }
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) safety test(s) failed. Do not deploy." }
Write-Host "Safety suite: $($result.PassedCount) passed."
- task: PublishTestResults@2
displayName: 'Publish safety results'
condition: succeededOrFailed()
inputs:
testResultsFormat: NUnit
testResultsFiles: './testResults.safety.xml'
testRunTitle: 'Persona Engine - safety suite'
- stage: IntegrationSuite
displayName: 'Integration suite (tenant required)'
dependsOn: OfflineSuites
condition: and(succeeded(), eq('${{ parameters.runIntegration }}', true))
jobs:
- job: Integration
displayName: 'Integration suite'
steps:
- checkout: self
- task: PowerShell@2
displayName: 'Integration suite'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
# No credential handling here by design. The agent must already hold a
# delegated read-only connection; if it does not, this fails loudly
# rather than prompting or falling back to a stored secret.
if (-not (Get-Module -ListAvailable Microsoft.Graph.Authentication)) {
throw 'Microsoft.Graph.Authentication is not available on this agent.'
}
$config = & ./tests/PesterConfiguration.ps1 -Suite Integration
$config.Run.Exit = $false
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './testResults.integration.xml'
$result = Invoke-Pester -Configuration $config
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) integration test(s) failed." }
- task: PublishTestResults@2
displayName: 'Publish integration results'
condition: succeededOrFailed()
inputs:
testResultsFormat: NUnit
testResultsFiles: './testResults.integration.xml'
testRunTitle: 'Persona Engine - integration suite'
-175
View File
@@ -1,175 +0,0 @@
# Persona Engine - validation stage
#
# Everything here runs with no tenant, no credentials, and no network (SC-008). That
# is the point: a pipeline that needs a directory connection to tell you a rule file
# is wrong cannot run on every pull request, and the check that only runs sometimes is
# the one that stops catching things.
#
# Gate order is deliberate, cheapest and most categorical first. Sanitization runs
# before anything else because a leaked identifier in a branch is a problem whether or
# not the code compiles, and every later stage prints file contents into build logs.
trigger:
branches:
include:
- main
paths:
include:
- src/*
- tests/*
- config/*
- pipelines/*
- Invoke-PersonaEngine.ps1
- Edit-PersonaEngineConfig.ps1
- PersonaEngine.psd1
- PersonaEngine.psm1
pr:
branches:
include:
- main
pool:
vmImage: windows-latest
variables:
# Pinned rather than latest. A validation stage that changes behaviour when an
# upstream module publishes is not a gate, it is a coin flip.
pesterVersion: '5.6.1'
analyzerVersion: '1.22.0'
steps:
- checkout: self
fetchDepth: 0
- task: PowerShell@2
displayName: 'Gate 1 - Sanitization (SC-013)'
inputs:
pwsh: true
filePath: tests/Test-Sanitization.ps1
failOnStderr: false
- task: PowerShell@2
displayName: 'Install pinned analysis modules'
inputs:
pwsh: true
targetType: inline
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Pester -RequiredVersion $(pesterVersion) -Force -SkipPublisherCheck -Scope CurrentUser
Install-Module PSScriptAnalyzer -RequiredVersion $(analyzerVersion) -Force -Scope CurrentUser
- task: PowerShell@2
displayName: 'Gate 2 - PSScriptAnalyzer'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$findings = Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1
if ($findings) {
$findings | Format-Table -AutoSize | Out-String | Write-Host
}
$blocking = @($findings | Where-Object Severity -in 'Error', 'Warning')
if ($blocking.Count -gt 0) {
throw "PSScriptAnalyzer reported $($blocking.Count) blocking finding(s)."
}
- task: PowerShell@2
displayName: 'Gate 3 - Engine purity (Principle IV)'
inputs:
pwsh: true
filePath: tests/Test-EnginePurity.ps1
- task: PowerShell@2
displayName: 'Gate 4 - Shipped schema is valid JSON Schema'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
# V-5a: Test-Json returns $true when the schema itself cannot be parsed, so a
# broken schema would let every later check pass while validating nothing.
# This stage exists solely to catch that.
$errors = $null
$null = '{}' | Test-Json -SchemaFile ./config/persona-engine.schema.json -ErrorAction SilentlyContinue -ErrorVariable errors
$unusable = @($errors | Where-Object { $_.Exception.Message -match 'Cannot parse the JSON schema' })
if ($unusable.Count -gt 0) {
throw 'config/persona-engine.schema.json is not valid JSON Schema. No configuration can be schema-validated until it is repaired.'
}
# The contract copy and the shipped copy must stay identical, or a rule author
# reading the contract validates against a different schema than the engine.
$shipped = (Get-FileHash ./config/persona-engine.schema.json -Algorithm SHA256).Hash
$contract = (Get-FileHash ./specs/001-persona-engine/contracts/persona-engine.schema.json -Algorithm SHA256).Hash
if ($shipped -ne $contract) {
throw 'config/persona-engine.schema.json and the contract copy have diverged.'
}
- task: PowerShell@2
displayName: 'Gate 5 - Example configuration passes all four layers'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
if ($LASTEXITCODE -ne 0) { throw "The shipped example configuration failed validation with exit code $LASTEXITCODE." }
- task: PowerShell@2
displayName: 'Gate 6 - Offline Pester suite (SC-008)'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$config = & ./tests/PesterConfiguration.ps1 -Suite Offline
$config.Run.Exit = $false
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './testResults.offline.xml'
$config.Output.Verbosity = 'Normal'
$result = Invoke-Pester -Configuration $config
# Asserted, not assumed. A configuration change that silently filtered every
# test out would otherwise report a green pipeline over zero coverage.
if ($result.TotalCount -eq 0) { throw 'The offline suite ran no tests.' }
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) offline test(s) failed." }
Write-Host "Offline suite: $($result.PassedCount) passed, $($result.FailedCount) failed."
- task: PublishTestResults@2
displayName: 'Publish offline test results'
condition: succeededOrFailed()
inputs:
testResultsFormat: NUnit
testResultsFiles: './testResults.offline.xml'
testRunTitle: 'Persona Engine - offline suite'
- task: PowerShell@2
displayName: 'Gate 7 - No Graph module was loaded (SC-008)'
inputs:
pwsh: true
targetType: inline
script: |
# The proof that the offline suite is genuinely offline. If a test ever
# imports the Graph SDK, the "runs with no tenant" claim quietly stops being
# true and nobody notices until an air-gapped build fails.
$config = & ./tests/PesterConfiguration.ps1 -Suite Offline
$config.Run.PassThru = $true
$config.Output.Verbosity = 'None'
$null = Invoke-Pester -Configuration $config
$graph = Get-Module | Where-Object Name -like 'Microsoft.Graph*'
if ($graph) {
throw "A Graph module was loaded during the offline suite: $($graph.Name -join ', ')"
}
Write-Host 'No Graph module was loaded. SC-008 holds.'
@@ -103,7 +103,7 @@
}, },
"path": { "path": {
"type": "string", "type": "string",
"description": "Placeholder in committed artifacts: <LOG-OUTPUT-PATH>" "description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
}, },
"traceConditionValues": { "traceConditionValues": {
"type": "boolean", "type": "boolean",
-133
View File
@@ -1,133 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-027: the interactive editor can add a new rule, and rejects a colliding id or
priority before the rule is added.
Add-PersonaConfigRule is exercised directly rather than through the interactive
script - it is the pure function the [A] command calls, and pure functions are
what this suite can test without driving Read-Host through a subprocess (see
NonInteractive.Tests.ps1 for why that path is reserved for hang/prompt proof, not
ordinary behaviour).
#>
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-TestRule {
param(
[string] $Id = 'RULE-0500-TEST',
[int] $Priority = 500,
[string] $Persona = 'Employee'
)
[pscustomobject]@{
id = $Id
name = 'Test rule'
description = 'A rule added by a test.'
enabled = $true
priority = $Priority
persona = $Persona
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
}
}
Describe 'Add-PersonaConfigRule (FR-027)' {
It 'appends the new rule to the collection' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$new = New-TestRule -Id 'RULE-0500-TEST' -Priority 500
$result = Add-PersonaConfigRule -Rules $existing -Rule $new
$result.Count | Should -Be 2
($result | Where-Object { [string]$_.id -eq 'RULE-0500-TEST' }).Count | Should -Be 1
}
It 'does not mutate the input collection' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$new = New-TestRule -Id 'RULE-0500-TEST' -Priority 500
Add-PersonaConfigRule -Rules $existing -Rule $new | Out-Null
$existing.Count | Should -Be 1
}
It 'always returns an array, even when the result has exactly one rule' {
# The single-element case is the one PowerShell silently collapses to a
# scalar unless the function guards against it (see Add-PersonaConfigRule's
# `, (...)` return) - assigning the call's result directly, with no extra
# @() at this call site, is what proves the function's own guard is doing
# the work rather than an @() here masking a function that does not.
$new = New-TestRule -Id 'RULE-0500-TEST' -Priority 500
$result = Add-PersonaConfigRule -Rules @() -Rule $new
, $result | Should -BeOfType [array]
$result.Count | Should -Be 1
}
It 'rejects a duplicate id before adding' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$colliding = New-TestRule -Id 'RULE-0010-GUEST' -Priority 999
{ Add-PersonaConfigRule -Rules $existing -Rule $colliding } | Should -Throw '*RULE-0010-GUEST*'
}
It 'rejects a duplicate priority before adding' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$colliding = New-TestRule -Id 'RULE-0500-TEST' -Priority 10
{ Add-PersonaConfigRule -Rules $existing -Rule $colliding } | Should -Throw '*10*'
}
It 'leaves the original collection unchanged when the add is rejected' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$colliding = New-TestRule -Id 'RULE-0010-GUEST' -Priority 999
try { Add-PersonaConfigRule -Rules $existing -Rule $colliding } catch { }
$existing.Count | Should -Be 1
}
It 'rejects a blank id' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$blank = New-TestRule -Id '' -Priority 999
{ Add-PersonaConfigRule -Rules $existing -Rule $blank } | Should -Throw
}
It 'the added rule survives a full validation pass' {
$document = New-TestConfigurationDocument
$new = @{
id = 'RULE-0500-TEST'
name = 'Test rule'
description = 'A rule added by a test.'
enabled = $true
priority = 500
persona = 'Employee'
match = @{
operator = 'all'
conditions = @(@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
$document.rules = Add-PersonaConfigRule -Rules $document.rules -Rule $new
$path = Save-TestConfiguration -Document $document -Directory ([System.IO.Path]::GetTempPath())
try {
$result = Test-PersonaConfiguration -Path $path
$result.IsValid | Should -BeTrue
@($result.Document.rules).Count | Should -Be 3
}
finally {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
}
@@ -1,95 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-029: the interactive editor can delete an existing rule.
Remove-PersonaConfigRule is the pure function the [D] command calls after the
operator confirms against the rule's id, name, and priority - the confirmation
prompt itself lives in Edit-PersonaEngineConfig.ps1 and is covered by
EditorStructuralEdits.Tests.ps1, which drives the real interactive loop.
#>
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-TestRule {
param([string] $Id, [int] $Priority)
[pscustomobject]@{
id = $Id
name = "Rule $Id"
description = 'A test rule.'
enabled = $true
priority = $Priority
persona = 'Employee'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
}
}
Describe 'Remove-PersonaConfigRule (FR-029)' {
It 'removes the named rule' {
$rules = @(
(New-TestRule -Id 'RULE-0010-GUEST' -Priority 10),
(New-TestRule -Id 'RULE-0900-EMPLOYEE' -Priority 900)
)
$result = Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-0010-GUEST'
, $result | Should -BeOfType [array]
$result.Count | Should -Be 1
$result[0].id | Should -Be 'RULE-0900-EMPLOYEE'
}
It 'does not mutate the input collection' {
$rules = @(
(New-TestRule -Id 'RULE-0010-GUEST' -Priority 10),
(New-TestRule -Id 'RULE-0900-EMPLOYEE' -Priority 900)
)
Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-0010-GUEST' | Out-Null
$rules.Count | Should -Be 2
}
It 'throws when the id does not exist, rather than silently doing nothing' {
$rules = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
{ Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-9999-ABSENT' } | Should -Throw '*RULE-9999-ABSENT*'
}
It 'can remove the only remaining rule, leaving an empty collection' {
$rules = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$result = Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-0010-GUEST'
, $result | Should -BeOfType [array]
$result.Count | Should -Be 0
}
It 'a deletion that leaves zero enabled rules is caught by re-validation (PE-SEM-003)' {
# Remove-PersonaConfigRule itself has no opinion on "zero enabled rules" - that
# is VR-002's job. Exercised here to prove the editor's re-validation path
# would in fact block this rather than silently accepting it.
$document = New-TestConfigurationDocument
($document.rules | Where-Object { $_.id -eq 'RULE-0010-GUEST' }).enabled = $false
$document.rules = Remove-PersonaConfigRule -Rules $document.rules -RuleId 'RULE-0900-EMPLOYEE'
$path = Save-TestConfiguration -Document $document -Directory ([System.IO.Path]::GetTempPath())
try {
$result = Test-PersonaConfiguration -Path $path
$result.IsValid | Should -BeFalse
@($result.Findings | Where-Object Code -EQ 'PE-SEM-003').Count | Should -BeGreaterThan 0
}
finally {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
}
@@ -1,255 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-028: the interactive editor can edit an existing rule, including its condition
tree - adding, editing, removing, and renesting conditions and all/any groups.
Get-PersonaConditionNode, Add-PersonaConditionNode, Remove-PersonaConditionNode,
and Set-PersonaConditionLeaf are the pure functions the [E] command composes.
Editing a rule's top-level fields (name, priority, persona, ...) is a plain
property assignment - Get-PersonaConditionNode's own doc comment explains why -
so there is nothing there to unit test beyond what Add-PersonaConfigRule already
proves about the shape surviving validation.
Depth-limit rejection at edit time (FR-030 / acceptance scenario 11) is
deliberately not tested here: Add-PersonaConditionNode does not duplicate depth
math (see its doc comment), so that behaviour lives entirely in
Edit-PersonaEngineConfig.ps1's candidate-edit wrapper and is covered by
EditorStructuralEdits.Tests.ps1, which drives the real interactive loop.
#>
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-TestMatchGroup {
# root(all) -> [ leaf(Department isNotNull), group(any) -> [ leaf(UserType equals Guest) ] ]
[pscustomobject]@{
operator = 'all'
conditions = @(
[pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
[pscustomobject]@{
operator = 'any'
conditions = @(
[pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }
)
}
)
}
}
}
Describe 'Get-PersonaConditionNode (FR-028)' {
It 'returns the root group for an empty path' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @()
$node.operator | Should -Be 'all'
}
It 'navigates to a top-level leaf' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @(0)
$node.property | Should -Be 'Department'
}
It 'navigates into a nested group' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @(1)
$node.operator | Should -Be 'any'
}
It 'navigates to a leaf inside a nested group' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @(1, 0)
$node.property | Should -Be 'UserType'
}
It 'throws on an out-of-range index' {
$match = New-TestMatchGroup
{ Get-PersonaConditionNode -Group $match -Path @(9) } | Should -Throw '*out of range*'
}
It 'throws when a path segment tries to descend into a leaf' {
$match = New-TestMatchGroup
{ Get-PersonaConditionNode -Group $match -Path @(0, 0) } | Should -Throw '*leaf*'
}
}
Describe 'Add-PersonaConditionNode (FR-028)' {
It 'appends a leaf condition to the root group' {
$match = New-TestMatchGroup
$newLeaf = [pscustomobject]@{ type = 'property'; property = 'CompanyName'; operator = 'isNotNull' }
Add-PersonaConditionNode -Group $match -ParentPath @() -Node $newLeaf | Out-Null
@($match.conditions).Count | Should -Be 3
$match.conditions[2].property | Should -Be 'CompanyName'
}
It 'appends into a nested group by path' {
$match = New-TestMatchGroup
$newLeaf = [pscustomobject]@{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' }
Add-PersonaConditionNode -Group $match -ParentPath @(1) -Node $newLeaf | Out-Null
$nested = Get-PersonaConditionNode -Group $match -Path @(1)
@($nested.conditions).Count | Should -Be 2
}
It 'appends a whole nested group as the new node (renesting)' {
$match = New-TestMatchGroup
$newGroup = [pscustomobject]@{
operator = 'any'
conditions = @([pscustomobject]@{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' })
}
Add-PersonaConditionNode -Group $match -ParentPath @() -Node $newGroup | Out-Null
$added = Get-PersonaConditionNode -Group $match -Path @(2)
$added.operator | Should -Be 'any'
}
It 'throws when the target path is a leaf, not a group' {
$match = New-TestMatchGroup
$newLeaf = [pscustomobject]@{ type = 'property'; property = 'X'; operator = 'isNotNull' }
{ Add-PersonaConditionNode -Group $match -ParentPath @(0) -Node $newLeaf } | Should -Throw '*leaf*'
}
}
Describe 'Remove-PersonaConditionNode (FR-028)' {
It 'removes a top-level leaf' {
$match = New-TestMatchGroup
Remove-PersonaConditionNode -Group $match -Path @(0) | Out-Null
@($match.conditions).Count | Should -Be 1
$match.conditions[0].operator | Should -Be 'any'
}
It 'removes a leaf nested inside a group' {
$match = New-TestMatchGroup
Add-PersonaConditionNode -Group $match -ParentPath @(1) `
-Node ([pscustomobject]@{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' }) | Out-Null
Remove-PersonaConditionNode -Group $match -Path @(1, 1) | Out-Null
$nested = Get-PersonaConditionNode -Group $match -Path @(1)
@($nested.conditions).Count | Should -Be 1
$nested.conditions[0].property | Should -Be 'UserType'
}
It 'removes a whole nested group in one call' {
$match = New-TestMatchGroup
Remove-PersonaConditionNode -Group $match -Path @(1) | Out-Null
@($match.conditions).Count | Should -Be 1
}
It 'throws rather than removing the root group' {
$match = New-TestMatchGroup
{ Remove-PersonaConditionNode -Group $match -Path @() } | Should -Throw '*root*'
}
It 'throws rather than emptying a group down to zero conditions' {
$match = New-TestMatchGroup
{ Remove-PersonaConditionNode -Group $match -Path @(1, 0) } | Should -Throw '*only condition*'
}
It 'removal by position is unambiguous between identical siblings' {
$match = [pscustomobject]@{
operator = 'any'
conditions = @(
[pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
[pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
)
}
Remove-PersonaConditionNode -Group $match -Path @(0) | Out-Null
@($match.conditions).Count | Should -Be 1
}
}
Describe 'Set-PersonaConditionLeaf (FR-028)' {
It 'mutates the node in place, visible through the original tree reference' {
$match = New-TestMatchGroup
$leaf = Get-PersonaConditionNode -Group $match -Path @(0)
Set-PersonaConditionLeaf -Node $leaf -Fields @{ type = 'property'; property = 'CompanyName'; operator = 'isNotNull' } | Out-Null
$match.conditions[0].property | Should -Be 'CompanyName'
}
It 'clears stale fields when switching operator families (equals -> isNull)' {
$match = New-TestMatchGroup
$leaf = Get-PersonaConditionNode -Group $match -Path @(1, 0)
$leaf.value | Should -Be 'Guest'
Set-PersonaConditionLeaf -Node $leaf -Fields @{ type = 'property'; property = 'UserType'; operator = 'isNull' } | Out-Null
$leaf.PSObject.Properties['value'] | Should -BeNullOrEmpty
}
It 'clears a stale value when switching to an in/values operator' {
$match = New-TestMatchGroup
$leaf = Get-PersonaConditionNode -Group $match -Path @(1, 0)
Set-PersonaConditionLeaf -Node $leaf -Fields @{
type = 'property'; property = 'UserType'; operator = 'in'; values = @('Guest', 'Member')
} | Out-Null
$leaf.PSObject.Properties['value'] | Should -BeNullOrEmpty
@($leaf.values) | Should -Be @('Guest', 'Member')
}
It 'throws when the target node is a group, not a leaf' {
$match = New-TestMatchGroup
$group = Get-PersonaConditionNode -Group $match -Path @(1)
{ Set-PersonaConditionLeaf -Node $group -Fields @{ type = 'property'; property = 'X'; operator = 'isNotNull' } } | Should -Throw '*group*'
}
It 'an edited leaf survives a full validation pass' {
$document = New-TestConfigurationDocument
$rule = $document.rules | Where-Object { $_.id -eq 'RULE-0900-EMPLOYEE' }
# Replaced with a fresh pscustomobject tree - the shape Get-PersonaConditionNode
# expects and the shape ConvertFrom-Json actually produces (see its doc comment).
$rule.match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
$leaf = Get-PersonaConditionNode -Group $rule.match -Path @(0)
Set-PersonaConditionLeaf -Node $leaf -Fields @{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' } | Out-Null
$path = Save-TestConfiguration -Document $document -Directory ([System.IO.Path]::GetTempPath())
try {
$result = Test-PersonaConfiguration -Path $path
$result.IsValid | Should -BeTrue
}
finally {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
}
@@ -1,225 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-030 / acceptance scenarios 9-11 (spec.md User Story 5): the [A]/[E]/[D]
commands are actually wired into Edit-PersonaEngineConfig.ps1's interactive loop,
structural edits stay in memory until Save, and a depth-limit violation is
rejected at edit time using the real validator's own finding.
The pure functions behind these commands (Add-PersonaConfigRule,
Get/Add/Remove-PersonaConditionNode, Set-PersonaConditionLeaf) are covered
directly in EditorAddRule/EditorEditRule/EditorDeleteRule.Tests.ps1. This suite
exists to prove the wiring itself - the prompts, the menu, and the
candidate-edit-or-revert behaviour - which only exists inside the script, not in
a dot-sourceable function.
Same technique as NonInteractive.Tests.ps1: a child pwsh with stdin redirected
from a file. There, an EMPTY file proves the non-interactive path never reads it.
Here, a SCRIPTED file drives the interactive menu exactly as a human typing
answers would, which is the only way to exercise Read-Host-based prompts from
a top-level script that is not a dot-sourceable module.
#>
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-structural-{0}" -f [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $script:scratch -Force
function Invoke-EditorWithScriptedInput {
<#
Runs the editor interactively (no -NonInteractive) with stdin fed from a
sequence of scripted answers, one per line, and a hard timeout so a
prompt sequence that runs dry (and therefore blocks on Read-Host) fails
the test instead of hanging the suite.
#>
param(
[string[]] $ArgumentList,
[string[]] $Answers,
[int] $TimeoutSeconds = 60
)
$stdinFile = Join-Path $script:scratch ("in-{0}.txt" -f [guid]::NewGuid().ToString('N'))
Set-Content -LiteralPath $stdinFile -Value ($Answers -join "`n") -NoNewline -Encoding utf8NoBOM
$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', '-File', $script:editor) + $ArgumentList) `
-RedirectStandardInput $stdinFile `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr `
-PassThru -WindowStyle Hidden
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
$process.Kill($true)
return [pscustomobject]@{ ExitCode = -1; Output = 'TIMED OUT'; Error = ''; TimedOut = $true }
}
[pscustomobject]@{
ExitCode = $process.ExitCode
Output = (Get-Content -LiteralPath $stdout -Raw -ErrorAction SilentlyContinue)
Error = (Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue)
TimedOut = $false
}
}
function New-ScratchConfigPath {
param([hashtable] $Document = (New-TestConfigurationDocument))
Save-TestConfiguration -Document $Document -Directory $script:scratch
}
}
AfterAll {
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
}
Describe 'Interactive rule add/edit/delete (FR-027 - FR-030)' {
It 'adds a rule and persists it only after Save' {
$path = New-ScratchConfigPath
$answers = @(
'A'
'RULE-0500-TEST'; 'Test rule'; 'A rule added by a test'; '500'; 'Employee'; 'Y'
'all'
'C'; 'P'; 'isNotNull'; 'JobTitle'
'N'
'S'
'Q'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.ExitCode | Should -Be 0
$run.Output | Should -Match 'Rule RULE-0500-TEST added'
$run.Output | Should -Match 'Saved:'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
@($saved.rules | Where-Object { $_.id -eq 'RULE-0500-TEST' }).Count | Should -Be 1
}
It 'leaves the file untouched when an add is not saved' {
$path = New-ScratchConfigPath
$before = Get-Content -LiteralPath $path -Raw
$answers = @(
'A'
'RULE-0500-TEST'; 'Test rule'; 'A rule added by a test'; '500'; 'Employee'; 'Y'
'all'
'C'; 'P'; 'isNotNull'; 'JobTitle'
'N'
'Q'; 'y'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.ExitCode | Should -Be 0
(Get-Content -LiteralPath $path -Raw) | Should -Be $before
}
It 'rejects a duplicate rule id with a message naming the conflict, and does not save it' {
$path = New-ScratchConfigPath
$answers = @(
'A'
'RULE-0010-GUEST'; 'Duplicate'; 'Collides with an existing id'; '999'; 'Employee'; 'Y'
'all'
'C'; 'P'; 'isNotNull'; 'JobTitle'
'N'
'Q'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'RULE-0010-GUEST'
$run.Output | Should -Match 'Add cancelled'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
@($saved.rules).Count | Should -Be 2
}
It 'deletes a rule after confirmation and persists it only after Save' {
$path = New-ScratchConfigPath
$answers = @('D', 'RULE-0010-GUEST', 'y', 'S', 'Q')
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'Rule RULE-0010-GUEST deleted'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
@($saved.rules | Where-Object { $_.id -eq 'RULE-0010-GUEST' }).Count | Should -Be 0
}
It 'declining the delete confirmation leaves the rule in place' {
$path = New-ScratchConfigPath
$answers = @('D', 'RULE-0010-GUEST', 'n', 'Q')
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'Delete cancelled'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
@($saved.rules | Where-Object { $_.id -eq 'RULE-0010-GUEST' }).Count | Should -Be 1
}
It 'rejects a condition-tree edit that would exceed the configured nesting depth, using the real validator finding' {
# maxConditionDepth: 2 means root(1) -> leaf(2) is the deepest a rule may go.
# Nesting a group under the root puts that group's own children at depth 3,
# which PE-SEM-012 catches - the same code the runtime validator and the [V]
# command produce (see Test-PersonaConfigurationSemantic.ps1).
$document = New-TestConfigurationDocument
$document.engine.maxConditionDepth = 2
$path = New-ScratchConfigPath -Document $document
$before = Get-Content -LiteralPath $path -Raw
$answers = @(
'E'; 'RULE-0900-EMPLOYEE'
'C'; ''
'G'; 'any'
'C'; 'P'; 'isNotNull'; 'JobTitle'
'N'
'Q'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'Edit rejected'
$run.Output | Should -Match 'PE-SEM-012'
(Get-Content -LiteralPath $path -Raw) | Should -Be $before
}
It 'an accepted condition-tree edit is held in memory and appears after Save' {
$document = New-TestConfigurationDocument
$path = New-ScratchConfigPath -Document $document
$answers = @(
'E'; 'RULE-0900-EMPLOYEE'
'C'; ''
'C'; 'P'; 'isNotNull'; 'JobTitle'
'S'; 'Q'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'Rule RULE-0900-EMPLOYEE updated'
$run.Output | Should -Match 'Saved:'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
$rule = $saved.rules | Where-Object { $_.id -eq 'RULE-0900-EMPLOYEE' }
@($rule.match.conditions).Count | Should -Be 2
}
}
-130
View File
@@ -1,130 +0,0 @@
#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 $_ }
}
}
-127
View File
@@ -1,127 +0,0 @@
#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
}
}
@@ -1,124 +0,0 @@
#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'
}
}
-175
View File
@@ -1,175 +0,0 @@
#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'
}
}
}
}
-190
View File
@@ -1,190 +0,0 @@
#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
}
}
@@ -1,101 +0,0 @@
#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 }
}
}
@@ -1,109 +0,0 @@
#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'
}
}
-39
View File
@@ -1,39 +0,0 @@
<#
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
-107
View File
@@ -1,107 +0,0 @@
#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
}
}
-103
View File
@@ -1,103 +0,0 @@
#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
}
}
-132
View File
@@ -1,132 +0,0 @@
#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'
}
}
-171
View File
@@ -1,171 +0,0 @@
#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'
}
}
}
-114
View File
@@ -1,114 +0,0 @@
#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
}
}
-82
View File
@@ -1,82 +0,0 @@
#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'
}
}
-116
View File
@@ -1,116 +0,0 @@
#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'
}
}
@@ -1,99 +0,0 @@
#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'
}
}
}
-114
View File
@@ -1,114 +0,0 @@
#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
}
}
-119
View File
@@ -1,119 +0,0 @@
#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'
}
}
-133
View File
@@ -1,133 +0,0 @@
#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
}
}
-128
View File
@@ -1,128 +0,0 @@
#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
}
}
-135
View File
@@ -1,135 +0,0 @@
#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'
}
}
-192
View File
@@ -1,192 +0,0 @@
#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
}
}
-102
View File
@@ -1,102 +0,0 @@
<#
.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
-138
View File
@@ -1,138 +0,0 @@
<#
.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
@@ -1,188 +0,0 @@
<#
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)
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,88 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,50 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,88 +0,0 @@
{
"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"
}
]
}
@@ -1,85 +0,0 @@
{
"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"
}
]
}
@@ -1,67 +0,0 @@
{
"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"
}
]
}
@@ -1,69 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,78 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,90 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
@@ -1,69 +0,0 @@
{
"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"
}
]
}
@@ -1,68 +0,0 @@
{
"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"
}
]
}
-111
View File
@@ -1,111 +0,0 @@
{
"_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"
}
]
}
-162
View File
@@ -1,162 +0,0 @@
{
"_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"
}
]
}
-380
View File
@@ -1,380 +0,0 @@
<#
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)
})
}
-170
View File
@@ -1,170 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-006, NFR-005: 100% of user events carry run ID, UPN, and Account Object ID, and
100% of matched results carry a rule ID.
"100%" is asserted across a whole run rather than on a single hand-built record.
A record builder can be correct in isolation and still be called wrongly on one
branch, and the branch that gets missed is invariably the error path - which is
the one an auditor will ask about.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:runId = '00000000-0000-0000-0000-00000000f001'
$script:rules = @(
(New-TestMembershipRule)
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
)
$script:config = New-TestRuntimeConfiguration -TargetAttribute $target -Rules $script:rules
}
Describe 'Audit completeness across a full run (SC-006)' {
BeforeAll {
Mock Get-PersonaUsers { @(New-TestPopulation -Count 24 -TargetAttribute $script:target) }
Mock Write-Host { }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
# A mixed run: some lookups fail so EvaluationError records are produced too.
$script:lookup = 0
Mock Get-PersonaGroupMembership {
$script:lookup++
($script:lookup % 4) -eq 0 `
? (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts') `
: (New-PersonaMembershipRecord -AllRetrieved)
}
$info = $null
$script:outcome = Invoke-PersonaEngineRun -Configuration $script:config -TargetAttribute $script:target `
-Context (New-TestAuditContext -Mode 'Enforce' -RunId $script:runId) `
-AuditParameters @{ Destination = 'stream' } `
-IsEnforcing -ShouldProcessGate { param($t, $d) $true } `
-InformationVariable info
$script:records = Get-CapturedAuditRecord -Captured $info
$script:userEvents = Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent'
}
It 'emits one UserEvent per processed user' {
$script:userEvents.Count | Should -Be $script:outcome.Counters.Processed
}
It 'carries the run ID on every record, not only user events' {
foreach ($record in $script:records) {
$record['runId'] | Should -Be $script:runId
}
}
It 'carries the UPN and Account Object ID on every user event' {
foreach ($event in $script:userEvents) {
$event['userPrincipalName'] | Should -Not -BeNullOrEmpty
$event['accountObjectId'] | Should -Not -BeNullOrEmpty
}
}
It 'carries a matched rule ID on every Matched record' {
$matched = @($script:userEvents | Where-Object { $_['outcome'] -eq 'Matched' })
$matched.Count | Should -BeGreaterThan 0
foreach ($event in $matched) {
$event['matchedRuleId'] | Should -Not -BeNullOrEmpty
}
}
It 'carries an evaluationErrorReason on exactly the EvaluationError records' {
$errors = @($script:userEvents | Where-Object { $_['outcome'] -eq 'EvaluationError' })
$errors.Count | Should -BeGreaterThan 0
foreach ($event in $script:userEvents) {
if ($event['outcome'] -eq 'EvaluationError') {
$event['evaluationErrorReason'] | Should -Not -BeNullOrEmpty
}
else {
$event['evaluationErrorReason'] | Should -BeNullOrEmpty
}
}
}
It 'carries the configuration version and hash on every record' {
foreach ($record in $script:records) {
$record['configVersion'] | Should -Be '1.0.0'
$record['configurationHash'] | Should -Not -BeNullOrEmpty
}
}
It 'carries the mode on every record' {
foreach ($record in $script:records) {
$record['mode'] | Should -Be 'Enforce'
}
}
It 'carries an ISO 8601 UTC timestamp on every record' {
foreach ($record in $script:records) {
$record['timestamp'] | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$'
}
}
It 'records previousValue on every Updated event and on no other' {
# Captured at write time. Without it, OTD-010 rollback is impossible
# retroactively - no later run can reconstruct what a value used to be.
$updated = @($script:userEvents | Where-Object { $_['action'] -eq 'Updated' })
$updated.Count | Should -BeGreaterThan 0
foreach ($event in $script:userEvents) {
if ($event['action'] -eq 'Updated') {
$event.Contains('previousValue') | Should -BeTrue
}
else {
$event['previousValue'] | Should -BeNullOrEmpty
}
}
}
It 'emits exactly one outcome value per user event' {
foreach ($event in $script:userEvents) {
$event['outcome'] | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError')
}
}
}
Describe 'Run report completeness' {
It 'produces a RunComplete record carrying the counters and the exit code' {
$counters = New-PersonaRunCounter -Rules $script:rules
$counters.Processed = 10
$counters.Matched = 8
$counters.Unclassified = 2
$record = Export-PersonaRunReport -Context (New-TestAuditContext) -Counters $counters `
-StartedUtc ([DateTime]::UtcNow.AddMinutes(-5)) -ExitCode 0
$record['recordType'] | Should -Be 'RunComplete'
$record['processed'] | Should -Be 10
$record['exitCode'] | Should -Be 0
$record['reconciliationPassed'] | Should -BeTrue
$record['startedUtc'] | Should -Match '^\d{4}-\d{2}-\d{2}T'
$record['completedUtc'] | Should -Match '^\d{4}-\d{2}-\d{2}T'
$record['durationMs'] | Should -BeGreaterThan 0
}
It 'refuses an exit code outside the documented range' {
$counters = New-PersonaRunCounter -Rules @()
{ Export-PersonaRunReport -Context (New-TestAuditContext) -Counters $counters `
-StartedUtc ([DateTime]::UtcNow) -ExitCode 9 } | Should -Throw
}
}
-221
View File
@@ -1,221 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
Each record type matches contracts/audit-record.md.
The contract is what downstream ingestion will be written against, so a field
quietly renamed here breaks a consumer that this repository never sees. These
tests are the contract's enforcement.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:envelope = @('timestamp', 'recordType', 'runId', 'engineVersion', 'configVersion', 'configurationHash', 'mode')
$script:counterFields = @('processed', 'matched', 'unclassified', 'evaluationError',
'unchanged', 'wouldUpdate', 'updated', 'updateFailed', 'skipped', 'reconciliationPassed')
function New-Result {
param(
[string] $Outcome = 'Matched',
[string] $Action = 'Unchanged',
[string] $MatchedRuleId = 'RULE-0010-GUEST',
[string] $ErrorReason = $null
)
[pscustomobject]@{
AccountObjectId = '00000000-0000-0000-0000-000000000101'
UserPrincipalName = 'alex@example.invalid'
Outcome = $Outcome
MatchedRuleId = $MatchedRuleId
CalculatedPersona = 'Guest'
StoredPersona = 'Employee'
Action = $Action
EvaluationErrorReason = $ErrorReason
RulesEvaluated = 3
DurationMs = 12
ConditionTrace = $null
}
}
}
Describe 'Common envelope' {
It 'appears on every record type' {
$counters = New-PersonaRunCounter -Rules @()
$context = New-TestAuditContext
$records = @(
New-PersonaAuditRecord -Context $context -RecordType 'RunStart'
New-PersonaAuditRecord -Context $context -RecordType 'UserEvent' -Result (New-Result)
New-PersonaAuditRecord -Context $context -RecordType 'Summary' -Counters $counters
New-PersonaAuditRecord -Context $context -RecordType 'EngineDefect' -Properties @{ severity = 'Error' }
Export-PersonaRunReport -Context $context -Counters $counters -StartedUtc ([DateTime]::UtcNow) -ExitCode 0
)
foreach ($record in $records) {
foreach ($field in $script:envelope) {
$record.Contains($field) | Should -BeTrue -Because "$($record['recordType']) must carry '$field'"
}
}
}
It 'places the envelope first, so a truncated line still identifies the run' {
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result (New-Result)
@($record.Keys)[0..6] | Should -Be $script:envelope
}
}
Describe 'UserEvent shape' {
It 'carries every documented field' {
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result (New-Result)
foreach ($field in @('accountObjectId', 'userPrincipalName', 'outcome', 'matchedRuleId',
'storedPersona', 'calculatedPersona', 'previousValue', 'action',
'rulesEvaluated', 'durationMs', 'evaluationErrorReason')) {
$record.Contains($field) | Should -BeTrue -Because "the contract names '$field'"
}
}
It 'populates previousValue only on an Updated action' {
$updated = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' `
-Result (New-Result -Action 'Updated') -PreviousValue 'Employee'
$wouldUpdate = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' `
-Result (New-Result -Action 'WouldUpdate') -PreviousValue 'Employee'
$updated['previousValue'] | Should -Be 'Employee'
$wouldUpdate['previousValue'] | Should -BeNullOrEmpty
}
It 'carries a null matchedRuleId on an Unclassified record' {
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' `
-Result (New-Result -Outcome 'Unclassified' -MatchedRuleId $null)
$record['outcome'] | Should -Be 'Unclassified'
$record['matchedRuleId'] | Should -BeNullOrEmpty
}
It 'carries an evaluationErrorReason on an EvaluationError record' {
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' `
-Result (New-Result -Outcome 'EvaluationError' -MatchedRuleId $null -Action 'Skipped' -ErrorReason 'membership lookup failed')
$record['evaluationErrorReason'] | Should -Be 'membership lookup failed'
}
It 'omits conditionTrace unless tracing is explicitly requested' {
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result (New-Result)
$record.Contains('conditionTrace') | Should -BeFalse
}
It 'refuses to build a UserEvent without a result' {
{ New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' } | Should -Throw
}
}
Describe 'Summary shape' {
It 'carries the summary type, counters, and rule counts' {
$rules = @(
[pscustomobject]@{ id = 'RULE-0010'; name = 'Guests'; enabled = $true; priority = 10; persona = 'Guest'; match = $null }
[pscustomobject]@{ id = 'RULE-0020'; name = 'Disabled'; enabled = $false; priority = 20; persona = 'Employee'; match = $null }
)
$counters = New-PersonaRunCounter -Rules $rules
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'Summary' `
-Counters $counters -Properties @{ summaryType = 'Interim' }
$record['summaryType'] | Should -Be 'Interim'
foreach ($field in $script:counterFields) { $record.Contains($field) | Should -BeTrue }
$record['ruleCounts'].Count | Should -Be 2
$record['ruleCounts'][0]['ruleId'] | Should -Be 'RULE-0010'
$record['ruleCounts'][1]['enabled'] | Should -BeFalse
}
It 'defaults to Interim when no summary type is given' {
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'Summary' `
-Counters (New-PersonaRunCounter -Rules @())
$record['summaryType'] | Should -Be 'Interim'
}
It 'refuses to build a Summary without counters' {
{ New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'Summary' } | Should -Throw
}
}
Describe 'RunComplete shape' {
It 'carries the timing fields, counters, and exit code' {
$record = Export-PersonaRunReport -Context (New-TestAuditContext) `
-Counters (New-PersonaRunCounter -Rules @()) -StartedUtc ([DateTime]::UtcNow.AddSeconds(-3)) -ExitCode 4
foreach ($field in (@('startedUtc', 'completedUtc', 'durationMs', 'exitCode') + $script:counterFields)) {
$record.Contains($field) | Should -BeTrue
}
$record['exitCode'] | Should -Be 4
}
}
Describe 'NDJSON serialization' {
It 'writes one record per line to file' {
$path = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-ndjson-{0}.log" -f [guid]::NewGuid().ToString('N'))
$context = New-TestAuditContext
try {
1..3 | ForEach-Object {
New-PersonaAuditRecord -Context $context -RecordType 'UserEvent' -Result (New-Result) |
Write-PersonaAuditRecord -Destination 'file' -Path $path
}
$lines = @(Get-Content -LiteralPath $path)
$lines.Count | Should -Be 3
foreach ($line in $lines) {
$line | Should -Not -Match "`n"
{ $line | ConvertFrom-Json } | Should -Not -Throw
($line | ConvertFrom-Json).recordType | Should -Be 'UserEvent'
}
}
finally { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue }
}
It 'emits nothing when the destination is none' {
$path = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-none-{0}.log" -f [guid]::NewGuid().ToString('N'))
New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'RunStart' |
Write-PersonaAuditRecord -Destination 'none' -Path $path
Test-Path -LiteralPath $path | Should -BeFalse
}
It 'warns once and keeps running when the file sink fails' {
# A locked log file is an operational problem with the sink, not a reason to
# abandon a run mid-population and leave the directory half-reconciled.
$state = New-PersonaAuditSinkState
$badPath = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-bad-{0}" -f [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $badPath -Force
try {
$warnings = @()
1..5 | ForEach-Object {
New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'RunStart' |
Write-PersonaAuditRecord -Destination 'file' -Path $badPath -State $state 3>&1 |
ForEach-Object { $warnings += $_ }
}
$state.FileSinkFailed | Should -BeTrue
@($warnings).Count | Should -Be 1
}
finally { Remove-Item -LiteralPath $badPath -Recurse -Force -ErrorAction SilentlyContinue }
}
}
-145
View File
@@ -1,145 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
Principle V: no token, Authorization header, secret, or raw Graph response can
appear in an audit record.
The strongest guarantee here is structural rather than filtered. New-PersonaAuditRecord
accepts only named, typed values from the decision result and the counters - there
is no pass-through of an arbitrary object, so there is nothing for a secret to ride
in on. These tests assert that property holds, and that it still holds when a
caller actively tries to smuggle one in.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
# Values that must never survive into a record, each distinctive enough to find in
# a serialized blob.
$script:secrets = @(
'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.SYNTHETIC.TOKEN'
'Bearer SYNTHETIC-ACCESS-TOKEN-VALUE'
'SYNTHETIC-CLIENT-SECRET-VALUE'
)
$script:forbiddenKeys = @('authorization', 'accesstoken', 'access_token', 'token',
'clientsecret', 'client_secret', 'secret', 'password', 'rawresponse', 'credential')
}
Describe 'Audit records contain no credential material' {
BeforeAll {
Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) }
Mock Write-Host { }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
$info = $null
$null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
-AuditParameters @{ Destination = 'stream' } `
-IsEnforcing -ShouldProcessGate { param($t, $d) $true } `
-InformationVariable info
$script:records = Get-CapturedAuditRecord -Captured $info
}
It 'produced records to inspect' {
$script:records.Count | Should -BeGreaterThan 0
}
It 'contains no field whose name suggests credential material' {
foreach ($record in $script:records) {
foreach ($key in $record.Keys) {
$key.ToLowerInvariant() | Should -Not -BeIn $script:forbiddenKeys
}
}
}
It 'contains nothing that looks like a JWT once serialized' {
foreach ($record in $script:records) {
($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match 'eyJ[A-Za-z0-9_-]{10,}'
}
}
It 'contains no Bearer prefix once serialized' {
foreach ($record in $script:records) {
($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match '(?i)bearer\s'
}
}
}
Describe 'A record cannot be made to carry a secret through the decision result' {
It 'ignores unexpected fields on the decision result' {
# A result object carrying an extra field - as it would if some future adapter
# attached a raw response - must not propagate it. The builder reads named
# fields only.
$result = [pscustomobject]@{
AccountObjectId = '00000000-0000-0000-0000-000000000101'
UserPrincipalName = 'a@example.invalid'
Outcome = 'Matched'
MatchedRuleId = 'RULE-0010'
CalculatedPersona = 'Employee'
StoredPersona = 'Employee'
Action = 'Unchanged'
EvaluationErrorReason = $null
RulesEvaluated = 1
DurationMs = 2
ConditionTrace = $null
AccessToken = $script:secrets[1]
RawGraphResponse = @{ Authorization = $script:secrets[0] }
}
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result
$record.Contains('AccessToken') | Should -BeFalse
$record.Contains('RawGraphResponse') | Should -BeFalse
($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match 'SYNTHETIC'
}
It 'keeps the UPN and Object ID, which are approved for logs' {
# Redaction must not go so far that the record stops being useful. NFR-005
# requires both fields on every user event.
$result = [pscustomobject]@{
AccountObjectId = '00000000-0000-0000-0000-000000000101'
UserPrincipalName = 'a@example.invalid'
Outcome = 'Matched'; MatchedRuleId = 'RULE-0010'
CalculatedPersona = 'Employee'; StoredPersona = 'Employee'
Action = 'Unchanged'; EvaluationErrorReason = $null
RulesEvaluated = 1; DurationMs = 2; ConditionTrace = $null
}
$record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result
$record['userPrincipalName'] | Should -Be 'a@example.invalid'
$record['accountObjectId'] | Should -Be '00000000-0000-0000-0000-000000000101'
}
}
Describe 'The Graph request helper never logs credential material' {
It 'does not write the request body or headers to the verbose stream' {
Mock Invoke-MgGraphRequest { @{ value = @() } }
$captured = Invoke-PersonaGraphRequest -Uri '/v1.0/users' -Body @{ secret = $script:secrets[2] } -Method 'PATCH' -Verbose 4>&1 |
Out-String
$captured | Should -Not -Match 'Bearer'
$captured | Should -Not -Match 'eyJ'
$captured | Should -Not -Match 'SYNTHETIC'
}
It 'reports a failure without echoing the response body' {
Mock Invoke-MgGraphRequest { throw 'Response status code does not indicate success: 403 (Forbidden).' }
{ Invoke-PersonaGraphRequest -Uri '/v1.0/users' } | Should -Throw
# A 403 is never retried, so the message surfaces once, unchanged, and carries
# only what Graph put in the status line.
Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly
}
}
-141
View File
@@ -1,141 +0,0 @@
#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
}
}
}
-279
View File
@@ -1,279 +0,0 @@
#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 'Target attribute unavailable - dev tenants without an app registration' {
BeforeAll {
# Mirrors what Graph actually sends for a bad $select property: a 400 with
# an empty body. Invoke-MgGraphRequest's message then names no property and
# carries no digits at all ("BadRequest", not "400") - the status is only
# recoverable from the structured Response.StatusCode, never from text. A
# fixture that embedded "400" or the property name in the message text would
# test a shape Graph does not actually produce.
function New-PersonaGraphBadRequestError {
$ex = [System.Exception]::new('Response status code does not indicate success: BadRequest (Bad Request).')
$ex | Add-Member -MemberType NoteProperty -Name Response -Value ([pscustomobject]@{ StatusCode = 400 }) -Force
[System.Management.Automation.ErrorRecord]::new($ex, 'GraphError', [System.Management.Automation.ErrorCategory]::InvalidResult, $null)
}
}
BeforeEach {
Mock Write-Host { }
Mock Write-Verbose { }
Mock Write-Warning { }
}
It 'continues in What-If mode, treating the attribute as null, when it is not registered' {
Mock Get-PersonaUsers {
param($SelectProperties, $UserObjectId, $PageSize)
if ($SelectProperties -ccontains $script:target) { throw (New-PersonaGraphBadRequestError) }
@(New-TestPopulation -Count 5 -TargetAttribute $script:target)
}
$outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext) -IsEnforcing:$false
$outcome.ExitCode | Should -Be 0
$outcome.Counters.Processed | Should -Be 5
Should -Invoke Get-PersonaUsers -Times 2 -Exactly
Should -Invoke Write-Warning -Times 1 -Exactly
}
It 'requests everything except the target attribute on the retry' {
Mock Get-PersonaUsers {
param($SelectProperties, $UserObjectId, $PageSize)
if ($SelectProperties -ccontains $script:target) { throw (New-PersonaGraphBadRequestError) }
@(New-TestPopulation -Count 5 -TargetAttribute $script:target)
}
$null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext) -IsEnforcing:$false
Should -Invoke Get-PersonaUsers -Times 1 -Exactly -ParameterFilter { $SelectProperties -cnotcontains $script:target }
}
It 'also recovers for a single-user run (-UserObjectId)' {
Mock Get-PersonaUsers {
param($SelectProperties, $UserObjectId, $PageSize)
if ($SelectProperties -ccontains $script:target) { throw (New-PersonaGraphBadRequestError) }
@(New-TestPopulation -Count 1 -TargetAttribute $script:target)
}
$outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext) -IsEnforcing:$false `
-UserObjectId '00000000-0000-0000-0000-000000000001'
$outcome.ExitCode | Should -Be 0
$outcome.Counters.Processed | Should -Be 1
Should -Invoke Get-PersonaUsers -Times 2 -Exactly -ParameterFilter { $UserObjectId -eq '00000000-0000-0000-0000-000000000001' }
}
It 'still fails enumeration in enforcement mode - the fallback never applies to a real write run' {
Mock Get-PersonaUsers { throw (New-PersonaGraphBadRequestError) }
$outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing
$outcome.ExitCode | Should -Be 3
Should -Invoke Get-PersonaUsers -Times 1 -Exactly
}
It 'does not swallow an unrelated enumeration failure even in What-If mode' {
Mock Get-PersonaUsers { throw 'Graph request failed after 5 attempt(s) (last status: 503): service unavailable' }
$outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext) -IsEnforcing:$false
$outcome.ExitCode | Should -Be 3
Should -Invoke Get-PersonaUsers -Times 1 -Exactly
}
}
Describe 'Exit codes owned by the entry script' {
BeforeAll {
$script:entryText = Get-Content -LiteralPath $script:entryScript -Raw
}
It 'declares all seven documented codes' {
foreach ($name in @('EXIT_OK', 'EXIT_CONFIG', 'EXIT_AUTH', 'EXIT_ENUMERATION',
'EXIT_DATA', 'EXIT_RECONCILIATION', 'EXIT_UNEXPECTED')) {
$script:entryText | Should -Match "\`$$name\s*=\s*\d"
}
}
It 'assigns each code the value the contract documents' {
$expected = @{
'EXIT_OK' = 0; 'EXIT_CONFIG' = 1; 'EXIT_AUTH' = 2; 'EXIT_ENUMERATION' = 3
'EXIT_DATA' = 4; 'EXIT_RECONCILIATION' = 5; 'EXIT_UNEXPECTED' = 6
}
foreach ($name in $expected.Keys) {
$script:entryText | Should -Match "\`$$name\s*=\s*$($expected[$name])\b"
}
}
It 'returns exit code 1 without connecting when validation fails' {
# The ordering matters as much as the code: FR-002 requires validation to
# complete before any connection is attempted.
$configIndex = $script:entryText.IndexOf('exit $EXIT_CONFIG')
$connectIndex = $script:entryText.IndexOf('Connect-PersonaGraphInteractive')
$configIndex | Should -BeGreaterThan 0
$connectIndex | Should -BeGreaterThan $configIndex
}
It 'maps an authentication failure to code 2' {
$script:entryText | Should -Match '(?s)Connect-PersonaGraphInteractive.*?\$exitCode = \$EXIT_AUTH'
}
It 'falls back to code 6 for an unexpected error' {
$script:entryText | Should -Match '\$exitCode = \$EXIT_UNEXPECTED'
}
It 'writes a RunComplete record even on a fatal error' {
# A run that died at user 400 of 5000 has to leave evidence saying so.
$script:entryText | Should -Match '(?s)finally\s*\{.*Export-PersonaRunReport'
}
}
-106
View File
@@ -1,106 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-001: every processed user lands in exactly one outcome bucket.
Exclusivity is what makes reconciliation meaningful. If a user could be both
Matched and EvaluationError, the sum would still be checkable but would no longer
mean anything, and FR-021 would be verifying arithmetic rather than correctness.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
# A rule set that needs membership data, so a failed lookup produces
# EvaluationError and all three buckets are populated in one run.
$script:rules = @(
(New-TestMembershipRule)
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
)
$script:config = New-TestRuntimeConfiguration -TargetAttribute $target -Rules $script:rules
}
Describe 'Exactly one outcome per user (SC-001)' {
BeforeEach {
Mock Write-Host { }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
Mock Get-PersonaUsers { New-TestPopulation -Count 24 -TargetAttribute $script:target }
# Every fourth account fails its membership lookup, producing a genuine mix
# of all three outcomes rather than a run where only one bucket is exercised.
$script:lookup = 0
Mock Get-PersonaGroupMembership {
$script:lookup++
($script:lookup % 4) -eq 0 `
? (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts') `
: (New-PersonaMembershipRecord -AllRetrieved)
}
}
It 'accounts for every processed user across the three outcome buckets' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -ShouldProcessGate { param($t, $d) $false }
$sum = $outcome.Counters.Matched + $outcome.Counters.Unclassified + $outcome.Counters.EvaluationError
$sum | Should -Be $outcome.Counters.Processed
}
It 'populates all three buckets, so the sum is not trivially satisfied' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -ShouldProcessGate { param($t, $d) $false }
$outcome.Counters.Matched | Should -BeGreaterThan 0
$outcome.Counters.Unclassified | Should -BeGreaterThan 0
$outcome.Counters.EvaluationError | Should -BeGreaterThan 0
}
It 'accounts for every processed user across the action buckets too' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -ShouldProcessGate { param($t, $d) $false }
$actions = $outcome.Counters.Unchanged + $outcome.Counters.WouldUpdate +
$outcome.Counters.Updated + $outcome.Counters.UpdateFailed + $outcome.Counters.Skipped
$actions | Should -Be $outcome.Counters.Processed
}
It 'assigns a single outcome value to each decision result' {
$record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'a@example.invalid' -Properties @{ Department = 'Finance' } `
-Membership (New-PersonaMembershipRecord -AllRetrieved)
$result = Resolve-UserPersona -UserRecord $record -Rules $script:rules
@($result.Outcome).Count | Should -Be 1
$result.Outcome | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError')
}
It 'never reports an EvaluationError user as Matched' {
# The specific confusion FR-013 exists to prevent: an unevaluable account
# must never carry a persona, or the preserved value and the calculated value
# would both look authoritative.
$record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000102' `
-UserPrincipalName 'b@example.invalid' -Properties @{ Department = 'Finance' } `
-StoredPersona 'Employee' `
-Membership (New-PersonaMembershipRecord -DirectFailureReason 'lookup failed')
$result = Resolve-UserPersona -UserRecord $record -Rules $script:rules
$result.Outcome | Should -Be 'EvaluationError'
$result.MatchedRuleId | Should -BeNullOrEmpty
$result.CalculatedPersona | Should -BeNullOrEmpty
$result.StoredPersona | Should -Be 'Employee'
}
}
-126
View File
@@ -1,126 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-004: @odata.nextLink is followed to exhaustion, and a truncated enumeration
raises rather than returning a partial population.
The second half is the one worth the effort. A partial population reconciles
cleanly, produces a plausible summary, and reports success - so nothing
downstream can tell that half the tenant was never looked at.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
}
Describe 'User enumeration pagination (FR-004)' {
It 'follows nextLink across every page' {
$script:page = 0
Mock Invoke-PersonaGraphRequest {
$script:page++
$users = 1..3 | ForEach-Object {
New-TestGraphUser -Id ('00000000-0000-0000-0000-{0:d12}' -f (($script:page - 1) * 3 + $_)) `
-UserPrincipalName ("user{0}-{1}@example.invalid" -f $script:page, $_)
}
$response = @{ value = $users }
if ($script:page -lt 4) { $response['@odata.nextLink'] = "/v1.0/users?`$skiptoken=page$script:page" }
$response
}
$result = @(Get-PersonaUsers -SelectProperties @('id', 'userPrincipalName'))
$result.Count | Should -Be 12
Should -Invoke Invoke-PersonaGraphRequest -Times 4 -Exactly
}
It 'stops when nextLink is absent rather than looping' {
Mock Invoke-PersonaGraphRequest { @{ value = @(New-TestGraphUser -Id '00000000-0000-0000-0000-000000000001' -UserPrincipalName 'a@example.invalid') } }
$result = @(Get-PersonaUsers -SelectProperties @('id'))
$result.Count | Should -Be 1
Should -Invoke Invoke-PersonaGraphRequest -Times 1 -Exactly
}
It 'raises on a response with no value collection rather than returning what it has' {
$script:page = 0
Mock Invoke-PersonaGraphRequest {
$script:page++
if ($script:page -eq 1) {
return @{
value = @(New-TestGraphUser -Id '00000000-0000-0000-0000-000000000001' -UserPrincipalName 'a@example.invalid')
'@odata.nextLink' = '/v1.0/users?$skiptoken=abc'
}
}
# Page two comes back malformed. Returning page one alone would look like
# a complete, tiny tenant.
@{ error = 'unexpected' }
}
{ @(Get-PersonaUsers -SelectProperties @('id')) } | Should -Throw -ExpectedMessage '*partial population*'
}
It 'raises on a null response' {
Mock Invoke-PersonaGraphRequest { $null }
{ @(Get-PersonaUsers -SelectProperties @('id')) } | Should -Throw
}
It 'requests the maximum page size so a large tenant needs fewer round trips' {
Mock Invoke-PersonaGraphRequest -ParameterFilter { $Uri -match '\$top=999' } -MockWith { @{ value = @() } }
$null = @(Get-PersonaUsers -SelectProperties @('id'))
Should -Invoke Invoke-PersonaGraphRequest -Times 1 -Exactly
}
}
Describe 'Membership pagination' {
It 'follows nextLink and collects only group objects' {
$script:page = 0
Mock Invoke-PersonaGraphRequest {
$script:page++
if ($script:page -eq 1) {
return @{
value = @(
@{ '@odata.type' = '#microsoft.graph.group'; id = '00000000-0000-0000-0000-0000000000a0' }
# An administrative unit arriving on memberOf. Treating it as
# a group ID would never match, which reads as "not a member".
@{ '@odata.type' = '#microsoft.graph.administrativeUnit'; id = '00000000-0000-0000-0000-0000000000e0' }
)
'@odata.nextLink' = '/v1.0/users/x/memberOf?$skiptoken=abc'
}
}
@{ value = @(@{ '@odata.type' = '#microsoft.graph.group'; id = '00000000-0000-0000-0000-0000000000b0' }) }
}
$record = Get-PersonaGroupMembership -UserObjectId '00000000-0000-0000-0000-000000000101' -NeedDirect
$record.DirectRetrieved | Should -BeTrue
$record.DirectGroupObjectIds.Count | Should -Be 2
$record.DirectGroupObjectIds | Should -Contain '00000000-0000-0000-0000-0000000000a0'
$record.DirectGroupObjectIds | Should -Contain '00000000-0000-0000-0000-0000000000b0'
$record.DirectGroupObjectIds | Should -Not -Contain '00000000-0000-0000-0000-0000000000e0'
}
It 'contains a mid-pagination failure to the affected facet instead of returning a short list' {
# The critical case. A truncated membership list looks exactly like a user who
# left a group, and would silently reclassify them (FR-013).
Mock Invoke-PersonaGraphRequest { throw 'Graph 503 after 5 attempts' }
$record = Get-PersonaGroupMembership -UserObjectId '00000000-0000-0000-0000-000000000101' -NeedDirect
$record.DirectRetrieved | Should -BeFalse
$record.DirectFailureReason | Should -Not -BeNullOrEmpty
}
}
-156
View File
@@ -1,156 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-021, SC-007: Processed = Matched + Unclassified + EvaluationError at every
summary, and a mismatch is reported as an engine defect.
The forced-mismatch tests are the point. A reconciliation check that only ever
sees correct data proves that the arithmetic works, not that the failure path
does - and the failure path is the only part anyone will ever depend on.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:population = @(New-TestPopulation -Count 15 -TargetAttribute $target)
}
Describe 'Reconciliation arithmetic (FR-021)' {
It 'passes when the outcome buckets account for every processed user' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 7
$counters.Unclassified = 2
$counters.EvaluationError = 1
Test-PersonaReconciliation -Counters $counters | Should -BeTrue
}
It 'fails when a user was processed but landed in no bucket' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 7
$counters.Unclassified = 2
$counters.EvaluationError = 0
Test-PersonaReconciliation -Counters $counters | Should -BeFalse
}
It 'fails when a user was double-counted' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 8
$counters.Unclassified = 2
$counters.EvaluationError = 1
Test-PersonaReconciliation -Counters $counters | Should -BeFalse
}
It 'passes trivially on an empty run' {
Test-PersonaReconciliation -Counters (New-PersonaRunCounter -Rules @()) | Should -BeTrue
}
It 'ignores the action buckets, which would otherwise mask a lost user' {
# Skipped is a catch-all. If reconciliation checked the action buckets, a lost
# user absorbed into Skipped would keep the sums balanced on a broken run.
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 5
$counters.Matched = 5
$counters.Skipped = 99
Test-PersonaReconciliation -Counters $counters | Should -BeTrue
}
}
Describe 'Reconciliation failure detail' {
It 'reports the difference and its sign so a maintainer can tell loss from duplication' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 7
$counters.Unclassified = 2
$counters.EvaluationError = 0
$detail = Get-PersonaReconciliationDetail -Counters $counters
$detail.processed | Should -Be 10
$detail.outcomeTotal | Should -Be 9
$detail.difference | Should -Be 1
$detail.severity | Should -Be 'Error'
$detail.defect | Should -Be 'ReconciliationFailure'
}
It 'reports a negative difference when a user was double-counted' {
$counters = New-PersonaRunCounter -Rules @()
$counters.Processed = 10
$counters.Matched = 9
$counters.Unclassified = 2
(Get-PersonaReconciliationDetail -Counters $counters).difference | Should -Be -1
}
}
Describe 'Reconciliation over a real run (SC-007)' {
BeforeEach {
Mock Get-PersonaUsers { $script:population }
Mock Write-Host { }
}
It 'reconciles at every interim summary and at completion' {
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 5
$info = $null
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -AuditParameters @{ Destination = 'stream' } `
-InformationVariable info
$summaries = Get-CapturedAuditRecord -Captured $info -RecordType 'Summary'
# Three interim boundaries at 5, 10, 15 plus the final summary.
$summaries.Count | Should -BeGreaterThan 1
foreach ($summary in $summaries) {
$summary['reconciliationPassed'] | Should -BeTrue
$summary['processed'] | Should -Be ($summary['matched'] + $summary['unclassified'] + $summary['evaluationError'])
}
}
It 'returns exit code 5 and emits an EngineDefect when reconciliation fails' {
# The failure is forced by making Add-PersonaRunResult drop the outcome tally
# while still counting the user as processed - precisely the bookkeeping defect
# FR-021 exists to catch.
Mock Add-PersonaRunResult { $Counters.Processed++ }
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 0
$info = $null
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -AuditParameters @{ Destination = 'stream' } `
-InformationVariable info
$outcome.ExitCode | Should -Be 5
$defects = Get-CapturedAuditRecord -Captured $info -RecordType 'EngineDefect'
$defects.Count | Should -BeGreaterThan 0
$defects[0]['defect'] | Should -Be 'ReconciliationFailure'
$defects[0]['severity'] | Should -Be 'Error'
}
It 'marks the summary record itself as failed when reconciliation fails' {
Mock Add-PersonaRunResult { $Counters.Processed++ }
$config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 0
$info = $null
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext) -AuditParameters @{ Destination = 'stream' } `
-InformationVariable info
(Get-CapturedAuditRecord -Captured $info -RecordType 'Summary')[0]['reconciliationPassed'] | Should -BeFalse
}
}
-181
View File
@@ -1,181 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'src/Normalization/New-PersonaMembershipRecord.ps1')
. (Join-Path $repoRoot 'src/Normalization/New-PersonaUserRecord.ps1')
. (Join-Path $repoRoot 'src/Configuration/New-PersonaValidationFinding.ps1')
}
Describe 'New-PersonaMembershipRecord' {
Context 'retrieval flags default to unretrieved' {
It 'defaults every facet to $false so an unset flag means unknown, never "not a member"' {
# The safety-critical default. If any of these flip, a failed group
# lookup satisfies notMemberOf and privileged accounts get misclassified.
$record = New-PersonaMembershipRecord
$record.DirectRetrieved | Should -BeFalse
$record.TransitiveRetrieved | Should -BeFalse
$record.RolesRetrieved | Should -BeFalse
}
It 'sets a facet only when explicitly requested' {
$record = New-PersonaMembershipRecord -DirectRetrieved
$record.DirectRetrieved | Should -BeTrue
$record.TransitiveRetrieved | Should -BeFalse
}
It 'AllRetrieved sets every facet' {
$record = New-PersonaMembershipRecord -AllRetrieved
$record.DirectRetrieved | Should -BeTrue
$record.TransitiveRetrieved | Should -BeTrue
$record.RolesRetrieved | Should -BeTrue
}
}
Context 'independent facets (RE-007)' {
It 'carries direct and transitive membership simultaneously' {
# The reason this record has three facets rather than one mode: a rule
# set may ask for direct membership in one rule and transitive in
# another, and a single-mode record cannot answer both.
$record = New-PersonaMembershipRecord `
-DirectGroupObjectIds @('00000000-0000-0000-0000-0000000000a0') `
-TransitiveGroupObjectIds @('00000000-0000-0000-0000-0000000000a0', '00000000-0000-0000-0000-0000000000b0') `
-AllRetrieved
$record.DirectGroupObjectIds | Should -HaveCount 1
$record.TransitiveGroupObjectIds | Should -HaveCount 2
}
It 'contains a failure to the facet that failed' {
# A transitive lookup that times out must not make direct-membership
# conditions unevaluable — otherwise one slow endpoint becomes a
# tenant-wide outage.
$record = New-PersonaMembershipRecord -DirectRetrieved -RolesRetrieved -TransitiveFailureReason 'Graph 503'
$record.DirectRetrieved | Should -BeTrue
$record.RolesRetrieved | Should -BeTrue
$record.TransitiveRetrieved | Should -BeFalse
$record.TransitiveFailureReason | Should -Be 'Graph 503'
}
}
Context 'shape' {
It 'always returns arrays, never $null, for the identifier collections' {
$record = New-PersonaMembershipRecord -AllRetrieved
, $record.DirectGroupObjectIds | Should -BeOfType [System.Array]
, $record.TransitiveGroupObjectIds | Should -BeOfType [System.Array]
, $record.DirectoryRoleIds | Should -BeOfType [System.Array]
}
It 'refuses to be both retrieved and failed on the same facet' {
{ New-PersonaMembershipRecord -DirectRetrieved -DirectFailureReason 'boom' } |
Should -Throw '*cannot be both*'
}
It 'refuses AllRetrieved alongside any failure reason' {
{ New-PersonaMembershipRecord -AllRetrieved -RolesFailureReason 'boom' } |
Should -Throw '*cannot be both*'
}
}
}
Describe 'New-PersonaUserRecord' {
Context 'required identity fields' {
It 'requires a non-empty AccountObjectId' {
{ New-PersonaUserRecord -AccountObjectId '' -UserPrincipalName 'a@example.invalid' } |
Should -Throw
}
It 'requires a non-empty UserPrincipalName' {
{ New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' -UserPrincipalName '' } |
Should -Throw
}
}
Context 'property bag' {
BeforeAll {
$script:record = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'alex.employee@example.invalid' `
-UserType 'Member' `
-Properties @{ Department = 'Finance'; CompanyName = $null }
}
It 'looks up properties case-insensitively (RE-006)' {
$record.Properties['department'] | Should -Be 'Finance'
$record.Properties['DEPARTMENT'] | Should -Be 'Finance'
}
It 'returns $null for an absent property rather than throwing (FR-012)' {
$record.Properties['NoSuchProperty'] | Should -BeNullOrEmpty
}
It 'preserves an explicit null value' {
$record.Properties['CompanyName'] | Should -BeNullOrEmpty
}
It 'exposes intrinsic fields as addressable properties' {
$record.Properties['UserPrincipalName'] | Should -Be 'alex.employee@example.invalid'
$record.Properties['UserType'] | Should -Be 'Member'
$record.Properties['AccountEnabled'] | Should -Be $true
}
It 'lets an explicit property override an intrinsic default' {
$overridden = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'a@example.invalid' `
-Properties @{ UserType = 'Overridden' }
$overridden.Properties['UserType'] | Should -Be 'Overridden'
}
}
Context 'membership default' {
It 'never leaves Membership null' {
$record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' -UserPrincipalName 'a@example.invalid'
$record.Membership | Should -Not -BeNullOrEmpty
}
It 'defaults to a fully unretrieved record so membership conditions yield Unknown' {
# An absent lookup is unknown, never "member of nothing". A rule set
# with no membership conditions never consults this; one that does must
# get EvaluationError rather than a fabricated non-match.
$record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' -UserPrincipalName 'a@example.invalid'
$record.Membership.DirectRetrieved | Should -BeFalse
$record.Membership.TransitiveRetrieved | Should -BeFalse
$record.Membership.RolesRetrieved | Should -BeFalse
}
}
}
Describe 'New-PersonaValidationFinding' {
It 'derives the layer from the code namespace' {
(New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' -Location 'rules[0]' -Description 'd' -SuggestedResolution 'r').Layer |
Should -Be 'Semantic'
(New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-001' -Location 'engine' -Description 'd' -SuggestedResolution 'r').Layer |
Should -Be 'Safety'
}
It 'rejects a malformed finding code' {
{ New-PersonaValidationFinding -Severity Error -Code 'BAD-001' -Location 'x' -Description 'd' -SuggestedResolution 'r' } |
Should -Throw
}
It 'requires a suggested resolution' {
{ New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' -Location 'x' -Description 'd' -SuggestedResolution '' } |
Should -Throw
}
It 'rejects an unrecognized severity' {
{ New-PersonaValidationFinding -Severity Critical -Code 'PE-SEM-001' -Location 'x' -Description 'd' -SuggestedResolution 'r' } |
Should -Throw
}
}
-111
View File
@@ -1,111 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
NFR-003 and the OTD-007 retry table.
The non-retryable cases carry the operational weight. A 403 retried five times per
account turns an instant authorization failure into a long, expensive one, and
hammers a tenant that is already refusing - so status extraction has to work even
when the code appears only in the exception message, which is how
Invoke-MgGraphRequest reports several of its failures.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
function New-StatusError {
param([int] $Status, [string] $Reason = 'Failed')
[System.Management.Automation.ErrorRecord]::new(
[System.Exception]::new("Response status code does not indicate success: $Status ($Reason)."),
'GraphError', [System.Management.Automation.ErrorCategory]::InvalidResult, $null)
}
}
Describe 'Status extraction from an error record' {
It 'reads a status embedded only in the message' {
Get-PersonaGraphStatusCode -ErrorRecord (New-StatusError -Status 403 -Reason 'Forbidden') | Should -Be 403
}
It 'reads each status the retry table names' {
foreach ($status in @(400, 401, 403, 404, 409, 429, 500, 502, 503, 504)) {
Get-PersonaGraphStatusCode -ErrorRecord (New-StatusError -Status $status) | Should -Be $status
}
}
It 'returns null for a transport failure with no status anywhere' {
$record = [System.Management.Automation.ErrorRecord]::new(
[System.Exception]::new('The operation was canceled.'),
'Timeout', [System.Management.Automation.ErrorCategory]::OperationTimeout, $null)
Get-PersonaGraphStatusCode -ErrorRecord $record | Should -BeNullOrEmpty
}
It 'does not mistake an unrelated three-digit number for a status' {
$record = [System.Management.Automation.ErrorRecord]::new(
[System.Exception]::new('Processed 250 objects before the connection dropped.'),
'Transport', [System.Management.Automation.ErrorCategory]::ConnectionError, $null)
# 250 is outside the HTTP error range, so it is not treated as a status and
# the failure stays retryable - which is the correct answer for a dropped
# connection.
Get-PersonaGraphStatusCode -ErrorRecord $record | Should -BeNullOrEmpty
}
}
Describe 'Retry policy (OTD-007)' {
# One It per status rather than a loop: Should -Invoke counts across the whole It
# block, so a loop would accumulate calls and the second iteration would fail on
# the first one's arithmetic.
It 'never retries status <_>' -ForEach @(400, 401, 403, 404, 409) {
$status = $_
Mock Invoke-MgGraphRequest { throw "Response status code does not indicate success: $status (Failed)." }.GetNewClosure()
{ Invoke-PersonaGraphRequest -Uri '/v1.0/users' -BaseDelayMs 1 } | Should -Throw
Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly -Because "status $status is a client-side defect that retrying would only hide"
}
It 'retries a retryable status up to the attempt limit' {
Mock Invoke-MgGraphRequest { throw 'Response status code does not indicate success: 503 (Service Unavailable).' }
{ Invoke-PersonaGraphRequest -Uri '/v1.0/users' -MaxAttempts 3 -BaseDelayMs 1 } | Should -Throw
Should -Invoke Invoke-MgGraphRequest -Times 3 -Exactly
}
It 'retries a transport failure that carries no status' {
Mock Invoke-MgGraphRequest { throw 'The operation was canceled.' }
{ Invoke-PersonaGraphRequest -Uri '/v1.0/users' -MaxAttempts 2 -BaseDelayMs 1 } | Should -Throw
Should -Invoke Invoke-MgGraphRequest -Times 2 -Exactly
}
It 'returns as soon as an attempt succeeds' {
$script:attempts = 0
Mock Invoke-MgGraphRequest {
$script:attempts++
if ($script:attempts -lt 3) { throw 'Response status code does not indicate success: 429 (Too Many Requests).' }
@{ value = @('ok') }
}
$result = Invoke-PersonaGraphRequest -Uri '/v1.0/users' -BaseDelayMs 1
$result['value'] | Should -Be @('ok')
Should -Invoke Invoke-MgGraphRequest -Times 3 -Exactly
}
It 'passes the body through unchanged as JSON' {
# SC-005 depends on this: the body a test captures must be the body sent.
$script:sentBody = $null
Mock Invoke-MgGraphRequest { $script:sentBody = $Body; @{} }
$null = Invoke-PersonaGraphRequest -Uri '/v1.0/users/x' -Method 'PATCH' -Body @{ 'extension_x_Persona' = 'Employee' }
($script:sentBody | ConvertFrom-Json).'extension_x_Persona' | Should -Be 'Employee'
}
}
-127
View File
@@ -1,127 +0,0 @@
#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
}
}