updated docs, removed testing files, fixed logging
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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, T101–T103) or an Azure Automation account (T115–T121).
|
||||||
> a tenant connection (T055, T056, T101–T103) or an Azure Automation account (T115–T121).
|
|
||||||
>
|
>
|
||||||
> **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 |
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
"destination": "both",
|
"destination": "both",
|
||||||
"path": "<LOG-OUTPUT-PATH>",
|
|
||||||
"traceConditionValues": false
|
"traceConditionValues": false
|
||||||
},
|
},
|
||||||
"personas": [
|
"personas": [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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'
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 $_ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user