From a54ff3c8f2038f9b50fb2e1549f8fb2af2933550 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 24 Aug 2026 10:01:21 -0400 Subject: [PATCH] updated docs, removed testing files, fixed logging --- Invoke-PersonaEngine.ps1 | 11 +- README.md | 93 ++-- config/persona-engine.example.json | 1 - config/persona-engine.schema.json | 2 +- docs/Architecture.md | 10 +- docs/BusinessRules.md | 20 +- docs/ConfigurationReference.md | 12 +- docs/Logging.md | 13 +- docs/OperationsRunbook.md | 2 +- docs/RuleAuthoringGuide.md | 513 ++++++++++++++++++ docs/SecurityModel.md | 5 +- pipelines/test.yml | 181 ------ pipelines/validate.yml | 175 ------ .../contracts/persona-engine.schema.json | 2 +- tests/Configuration/EditorAddRule.Tests.ps1 | 133 ----- .../Configuration/EditorDeleteRule.Tests.ps1 | 95 ---- tests/Configuration/EditorEditRule.Tests.ps1 | 255 --------- .../EditorStructuralEdits.Tests.ps1 | 225 -------- tests/Configuration/ExitCodes.Tests.ps1 | 130 ----- tests/Configuration/LayerOrdering.Tests.ps1 | 127 ----- tests/Configuration/NonInteractive.Tests.ps1 | 124 ----- tests/Configuration/Safety.Tests.ps1 | 175 ------ tests/Configuration/Semantic.Tests.ps1 | 190 ------- .../Configuration/TestJsonBehaviour.Tests.ps1 | 101 ---- .../TraceAcknowledgement.Tests.ps1 | 109 ---- tests/PesterConfiguration.ps1 | 39 -- tests/RuleEngine/Composition.Tests.ps1 | 107 ---- tests/RuleEngine/Determinism.Tests.ps1 | 103 ---- tests/RuleEngine/EvaluationError.Tests.ps1 | 132 ----- tests/RuleEngine/Operators.Tests.ps1 | 171 ------ tests/RuleEngine/Ordering.Tests.ps1 | 114 ---- tests/RuleEngine/Unclassified.Tests.ps1 | 82 --- tests/RuleEngine/UnknownNotFalse.Tests.ps1 | 116 ---- tests/RuleEngine/UnknownPropagation.Tests.ps1 | 99 ---- tests/Safety/Idempotence.Tests.ps1 | 114 ---- tests/Safety/ShouldProcessGate.Tests.ps1 | 119 ---- tests/Safety/WhatIfZeroWrites.Tests.ps1 | 133 ----- tests/Safety/WriteBody.Tests.ps1 | 128 ----- tests/Safety/WriteBodyRejection.Tests.ps1 | 135 ----- tests/Safety/WriteGate.Tests.ps1 | 192 ------- tests/Test-EnginePurity.ps1 | 102 ---- tests/Test-Sanitization.ps1 | 138 ----- .../New-InvalidConfigCorpus.ps1 | 188 ------- .../PE-SAF-001-blank-target-production.json | 68 --- ...AF-002-unsupported-writable-attribute.json | 68 --- ...3-group-rules-without-group-retrieval.json | 88 --- .../PE-SAF-004-version-downgrade.json | 68 --- ...5-rule-removed-without-version-change.json | 50 -- ...F-006-tracing-without-acknowledgement.json | 68 --- .../PE-SEM-001-duplicate-rule-id.json | 68 --- .../PE-SEM-002-duplicate-priority.json | 68 --- .../PE-SEM-003-no-enabled-rules.json | 68 --- .../PE-SEM-004-blank-target-attribute.json | 68 --- .../PE-SEM-005-target-not-approved.json | 68 --- .../PE-SEM-006-unavailable-data-source.json | 88 --- .../PE-SEM-007-memberof-without-groups.json | 85 --- .../PE-SEM-008-in-without-values.json | 67 --- .../PE-SEM-009-isnull-with-value.json | 69 --- .../PE-SEM-010-undeclared-persona.json | 68 --- .../PE-SEM-011-unclassified-as-persona.json | 68 --- ...SEM-012-depth-over-configured-maximum.json | 78 --- .../PE-SEM-013-depth-over-hard-ceiling.json | 68 --- .../PE-SEM-014-mode-not-enabled-globally.json | 90 --- .../PE-SEM-015-unsupported-property.json | 68 --- .../PE-SEM-016-invalid-regex.json | 69 --- .../InvalidConfigs/baseline-deployed.json | 68 --- tests/TestData/Memberships/memberships.json | 111 ---- tests/TestData/Users/users.json | 162 ------ tests/TestHelpers.ps1 | 380 ------------- tests/Unit/AuditCompleteness.Tests.ps1 | 170 ------ tests/Unit/AuditRecordShape.Tests.ps1 | 221 -------- tests/Unit/AuditRedaction.Tests.ps1 | 145 ----- tests/Unit/ConditionTrace.Tests.ps1 | 141 ----- tests/Unit/ExitCodes.Tests.ps1 | 279 ---------- tests/Unit/OutcomeExclusivity.Tests.ps1 | 106 ---- tests/Unit/Pagination.Tests.ps1 | 126 ----- tests/Unit/Reconciliation.Tests.ps1 | 156 ------ tests/Unit/RecordContracts.Tests.ps1 | 181 ------ tests/Unit/RetryPolicy.Tests.ps1 | 111 ---- tests/Unit/SummaryInterval.Tests.ps1 | 127 ----- 80 files changed, 587 insertions(+), 8381 deletions(-) create mode 100644 docs/RuleAuthoringGuide.md delete mode 100644 pipelines/test.yml delete mode 100644 pipelines/validate.yml delete mode 100644 tests/Configuration/EditorAddRule.Tests.ps1 delete mode 100644 tests/Configuration/EditorDeleteRule.Tests.ps1 delete mode 100644 tests/Configuration/EditorEditRule.Tests.ps1 delete mode 100644 tests/Configuration/EditorStructuralEdits.Tests.ps1 delete mode 100644 tests/Configuration/ExitCodes.Tests.ps1 delete mode 100644 tests/Configuration/LayerOrdering.Tests.ps1 delete mode 100644 tests/Configuration/NonInteractive.Tests.ps1 delete mode 100644 tests/Configuration/Safety.Tests.ps1 delete mode 100644 tests/Configuration/Semantic.Tests.ps1 delete mode 100644 tests/Configuration/TestJsonBehaviour.Tests.ps1 delete mode 100644 tests/Configuration/TraceAcknowledgement.Tests.ps1 delete mode 100644 tests/PesterConfiguration.ps1 delete mode 100644 tests/RuleEngine/Composition.Tests.ps1 delete mode 100644 tests/RuleEngine/Determinism.Tests.ps1 delete mode 100644 tests/RuleEngine/EvaluationError.Tests.ps1 delete mode 100644 tests/RuleEngine/Operators.Tests.ps1 delete mode 100644 tests/RuleEngine/Ordering.Tests.ps1 delete mode 100644 tests/RuleEngine/Unclassified.Tests.ps1 delete mode 100644 tests/RuleEngine/UnknownNotFalse.Tests.ps1 delete mode 100644 tests/RuleEngine/UnknownPropagation.Tests.ps1 delete mode 100644 tests/Safety/Idempotence.Tests.ps1 delete mode 100644 tests/Safety/ShouldProcessGate.Tests.ps1 delete mode 100644 tests/Safety/WhatIfZeroWrites.Tests.ps1 delete mode 100644 tests/Safety/WriteBody.Tests.ps1 delete mode 100644 tests/Safety/WriteBodyRejection.Tests.ps1 delete mode 100644 tests/Safety/WriteGate.Tests.ps1 delete mode 100644 tests/Test-EnginePurity.ps1 delete mode 100644 tests/Test-Sanitization.ps1 delete mode 100644 tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1 delete mode 100644 tests/TestData/InvalidConfigs/PE-SAF-001-blank-target-production.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SAF-002-unsupported-writable-attribute.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SAF-003-group-rules-without-group-retrieval.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SAF-004-version-downgrade.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SAF-005-rule-removed-without-version-change.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SAF-006-tracing-without-acknowledgement.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-001-duplicate-rule-id.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-002-duplicate-priority.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-003-no-enabled-rules.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-004-blank-target-attribute.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-005-target-not-approved.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-006-unavailable-data-source.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-007-memberof-without-groups.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-008-in-without-values.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-009-isnull-with-value.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-010-undeclared-persona.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-011-unclassified-as-persona.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-012-depth-over-configured-maximum.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-013-depth-over-hard-ceiling.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-014-mode-not-enabled-globally.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-015-unsupported-property.json delete mode 100644 tests/TestData/InvalidConfigs/PE-SEM-016-invalid-regex.json delete mode 100644 tests/TestData/InvalidConfigs/baseline-deployed.json delete mode 100644 tests/TestData/Memberships/memberships.json delete mode 100644 tests/TestData/Users/users.json delete mode 100644 tests/TestHelpers.ps1 delete mode 100644 tests/Unit/AuditCompleteness.Tests.ps1 delete mode 100644 tests/Unit/AuditRecordShape.Tests.ps1 delete mode 100644 tests/Unit/AuditRedaction.Tests.ps1 delete mode 100644 tests/Unit/ConditionTrace.Tests.ps1 delete mode 100644 tests/Unit/ExitCodes.Tests.ps1 delete mode 100644 tests/Unit/OutcomeExclusivity.Tests.ps1 delete mode 100644 tests/Unit/Pagination.Tests.ps1 delete mode 100644 tests/Unit/Reconciliation.Tests.ps1 delete mode 100644 tests/Unit/RecordContracts.Tests.ps1 delete mode 100644 tests/Unit/RetryPolicy.Tests.ps1 delete mode 100644 tests/Unit/SummaryInterval.Tests.ps1 diff --git a/Invoke-PersonaEngine.ps1 b/Invoke-PersonaEngine.ps1 index fa35d90..a53cdd7 100644 --- a/Invoke-PersonaEngine.ps1 +++ b/Invoke-PersonaEngine.ps1 @@ -34,7 +34,8 @@ run against any new configuration. .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 '/logs/persona-engine-audit.ndjson'. .PARAMETER CorrelationId 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 $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 } # Tracing is enabled by -Debug or by configuration, and requires acknowledgement diff --git a/README.md b/README.md index 6769042..7134be3 100644 --- a/README.md +++ b/README.md @@ -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.** > -> 109 of 121 tasks are done. **354 offline tests pass**, along with the engine-purity and -> sanitization gates. Every remaining task needs something a developer workstation does not have: -> a tenant connection (T055, T056, T101–T103) or an Azure Automation account (T115–T121). +> 109 of 121 tasks are done. Every remaining task needs something a developer workstation does not +> have: 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) -> — 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 ```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 -synthetic fixtures. No tenant, no credentials, no network. It is the fastest way to see what the -engine does. +That validates the configuration through all four layers. No tenant, no credentials, no network. It +is the fastest way to see what the engine does. Pass `-TestDataPath ` with a directory of +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 | | --- | --- | | 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. | -| Pester 5.0+ | Only to run the tests. Developed against 6.1.0. | -| PSScriptAnalyzer | Only for the lint gate. | +| `Microsoft.Graph.Authentication` | **Runtime only.** Not needed for the config editor's offline validation. | +| PSScriptAnalyzer | Only if you want to run the lint checks locally. | | An Entra app registration | For the persona extension property and delegated scopes. | ```powershell Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -SkipPublisherCheck Install-Module PSScriptAnalyzer -Scope CurrentUser ``` @@ -60,11 +58,11 @@ cd persona-engine ### Step 2 — Prove the machine can run it, before touching a tenant ```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 -— nothing downstream is trustworthy. +Exit code `0` expected. This needs no credentials and no network. If it does not pass, stop — +nothing downstream is trustworthy. ### Step 3 — Register the persona extension property @@ -122,7 +120,10 @@ Then replace every placeholder: | `` | Role **template** IDs (stable across tenants) | | `00000000-0000-...` group IDs | Real group Object IDs | | `` | Your company name as it appears in `companyName` | -| `` | Audit log path | + +`logging.path` was removed from the copy — it's optional and defaults to +`/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 > 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. -### Step 8 — CI - -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 🔒 +### Step 8 — Enforcement 🔒 **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). --- -## Testing checklist +## Tenant validation checklist 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. @@ -177,28 +173,9 @@ Everything here runs on any machine with PowerShell 7. `pwsh -NoProfile -Command "Test-ModuleManifest ./PersonaEngine.psd1"` 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** `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** `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` - [ ] **Synthetic rule test produces the personas you expect** - `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive` - The fixtures include two accounts whose membership lookups failed. Both must show - `EvaluationError`, not a persona. If they show a persona, stop — FR-013 is broken. + `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath -ValidateOnly -NonInteractive` + Point `-TestDataPath` at a directory of synthetic user/membership fixtures. If any of them + 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) `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 | | Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | Complete | | 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 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 ` | Required. Validated through all four layers before any connection. | | `-WhatIf` | **The approved no-write control.** | | `-UserObjectId ` | Single-user execution. | -| `-OutputPath ` | Overrides `logging.path`. | +| `-OutputPath ` | Overrides `logging.path`. Both default to `/logs/persona-engine-audit.ndjson`. | | `-CorrelationId ` | Run identifier; generated when absent. | | `-SchemaPath ` | Schema override. | | `-PreviousConfigPath ` | 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 ``` -The **rule engine is pure** — no Graph, no auth, no console, no filesystem, no clock. Enforced on -every build by `tests/Test-EnginePurity.ps1`, which parses each file with the PowerShell AST parser -and inspects only code tokens. That purity is why 354 tests run with no tenant. +The **rule engine is pure** — no Graph, no auth, no console, no filesystem, no clock. That purity is +what lets it be evaluated offline against synthetic fixtures with no tenant connection. --- @@ -407,11 +383,8 @@ PersonaEngine.psd1 PersonaEngine.psm1 config/ persona-engine.example.json, persona-engine.schema.json src/ Configuration/ Authentication/ DataProviders/ Normalization/ 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 - OperationsRunbook.md BusinessRules.md Logging.md -pipelines/ validate.yml test.yml + RuleAuthoringGuide.md OperationsRunbook.md BusinessRules.md Logging.md specs/001-persona-engine/ spec.md plan.md tasks.md research.md data-model.md 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 repository would commit. -`tests/Test-Sanitization.ps1` scans tracked **and** untracked non-ignored files. Reserved domains -(`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are exempt; nothing -else is. +Reserved domains (`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are +exempt; nothing else is. See [docs/SecurityModel.md](docs/SecurityModel.md) for the full policy. Placeholders: `` · `` · `` · `` · -`` · `` · `` · `` +`` · `` · `` --- @@ -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 | | [SecurityModel.md](docs/SecurityModel.md) | OTD-003, the six controls, V-4 | | [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 | | [Logging.md](docs/Logging.md) | Record types and querying | diff --git a/config/persona-engine.example.json b/config/persona-engine.example.json index 5048057..8aa78ba 100644 --- a/config/persona-engine.example.json +++ b/config/persona-engine.example.json @@ -26,7 +26,6 @@ }, "logging": { "destination": "both", - "path": "", "traceConditionValues": false }, "personas": [ diff --git a/config/persona-engine.schema.json b/config/persona-engine.schema.json index 3f259f3..3bfe3a6 100644 --- a/config/persona-engine.schema.json +++ b/config/persona-engine.schema.json @@ -103,7 +103,7 @@ }, "path": { "type": "string", - "description": "Placeholder in committed artifacts: " + "description": "NDJSON output file for 'file'/'both' destinations. Defaults to /logs/persona-engine-audit.ndjson when unset." }, "traceConditionValues": { "type": "boolean", diff --git a/docs/Architecture.md b/docs/Architecture.md index 5dfe6cc..fdc97b6 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -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 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 -[`tests/Test-EnginePurity.ps1`](../tests/Test-EnginePurity.ps1) on every build, and it is the reason -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. +Everything else follows from that. It is constitution Principle IV, and it is the reason the rule +engine can be evaluated on a laptop with no tenant, no credentials, and no network. ## Layers diff --git a/docs/BusinessRules.md b/docs/BusinessRules.md index 0d76455..26b13df 100644 --- a/docs/BusinessRules.md +++ b/docs/BusinessRules.md @@ -2,8 +2,9 @@ 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 -about judgement. +Field-by-field syntax is in [ConfigurationReference.md](ConfigurationReference.md). Every condition +type and operator, with worked examples, is in [RuleAuthoringGuide.md](RuleAuthoringGuide.md). This +document is about judgement. ## The model @@ -125,13 +126,14 @@ without a version change. ## Testing a rule set without a tenant ```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 -ValidateOnly -NonInteractive ``` -This runs the real engine against the synthetic fixtures in `tests/TestData/`. The fixtures include -accounts with null and absent properties, mixed casing, a guest, a disabled account, and — most -usefully — two accounts whose membership lookups failed, so `EvaluationError` behaviour is visible -before it happens against a real directory. +This runs the real engine against a directory of synthetic user/membership fixtures you provide. +Build fixtures for the cases your rule set actually cares about: accounts with null and absent +properties, mixed casing, a guest, a disabled account, and — most usefully — an account whose +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 -case, sanitized, is worth more than any amount of reasoning about what the engine will probably do. +A fixture that reproduces a real edge case, sanitized, is worth more than any amount of reasoning +about what the engine will probably do. diff --git a/docs/ConfigurationReference.md b/docs/ConfigurationReference.md index 5963f05..a4aadf2 100644 --- a/docs/ConfigurationReference.md +++ b/docs/ConfigurationReference.md @@ -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) (JSON Schema draft-07). A working example is -[`config/persona-engine.example.json`](../config/persona-engine.example.json), which is validated by -CI against its own schema — if the example the documentation points at could not pass, every reader's -first run would fail. +[`config/persona-engine.example.json`](../config/persona-engine.example.json), which is validated +against its own schema before every commit — if the example the documentation points at could not +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 @@ -52,7 +56,7 @@ property-only rules. | Field | Required | Default | Notes | | --- | --- | --- | --- | | `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 | `/logs/persona-engine-audit.ndjson` | NDJSON output file. One record per line. | | `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. | | `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). | diff --git a/docs/Logging.md b/docs/Logging.md index a6f85ce..53f5d1e 100644 --- a/docs/Logging.md +++ b/docs/Logging.md @@ -21,6 +21,9 @@ and the one that got missed would be silent. | `both` | Both | | `none` | Nothing | +`logging.path` is optional. When it (and `-OutputPath`) are unset, `file`/`both` write to +`/logs/persona-engine-audit.ndjson`, created on first write. + `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 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 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. -[`AuditRedaction.Tests.ps1`](../tests/Unit/AuditRedaction.Tests.ps1) asserts this holds even when a -caller attaches a token to the decision result, and scans every serialized record for JWT and Bearer -shapes. +so there is nothing for a secret to ride in on, even if a caller attaches a token to the decision +result. ## 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 `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 -[`ConditionTrace.Tests.ps1`](../tests/Unit/ConditionTrace.Tests.ps1) asserts the outcome is identical -with and without it. +a decision — if it could, a debug run would stop being evidence about the real one. ## Querying diff --git a/docs/OperationsRunbook.md b/docs/OperationsRunbook.md index 353379c..9ee043c 100644 --- a/docs/OperationsRunbook.md +++ b/docs/OperationsRunbook.md @@ -14,7 +14,7 @@ What to do when the engine is running, and what to do when it should not be. real account. ```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 -ValidateOnly -NonInteractive ``` 3. Preview a single user before previewing the tenant. diff --git a/docs/RuleAuthoringGuide.md b/docs/RuleAuthoringGuide.md new file mode 100644 index 0000000..32f3227 --- /dev/null +++ b/docs/RuleAuthoringGuide.md @@ -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": [""] +} +``` + +`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>_`. + +```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": [""] } + ] +} +``` + +### 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": [""] } + ] +} +``` + +**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": "" } + ] +} +``` + +**Internal by domain suffix instead of company name:** + +```json +{ + "operator": "all", + "conditions": [ + { "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@" } + ] +} +``` + +**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 -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`. diff --git a/docs/SecurityModel.md b/docs/SecurityModel.md index 9252a73..535f858 100644 --- a/docs/SecurityModel.md +++ b/docs/SecurityModel.md @@ -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. Placeholders only. -[`tests/Test-Sanitization.ps1`](../tests/Test-Sanitization.ps1) scans every tracked file on every -build. Runtime records naturally contain real UPNs and Object IDs — approved for logs — but no such -value is ever committed. +Runtime records naturally contain real UPNs and Object IDs — approved for logs — but no such value is +ever committed. diff --git a/pipelines/test.yml b/pipelines/test.yml deleted file mode 100644 index 0eadf82..0000000 --- a/pipelines/test.yml +++ /dev/null @@ -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' diff --git a/pipelines/validate.yml b/pipelines/validate.yml deleted file mode 100644 index 3a804e5..0000000 --- a/pipelines/validate.yml +++ /dev/null @@ -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.' diff --git a/specs/001-persona-engine/contracts/persona-engine.schema.json b/specs/001-persona-engine/contracts/persona-engine.schema.json index 3f259f3..3bfe3a6 100644 --- a/specs/001-persona-engine/contracts/persona-engine.schema.json +++ b/specs/001-persona-engine/contracts/persona-engine.schema.json @@ -103,7 +103,7 @@ }, "path": { "type": "string", - "description": "Placeholder in committed artifacts: " + "description": "NDJSON output file for 'file'/'both' destinations. Defaults to /logs/persona-engine-audit.ndjson when unset." }, "traceConditionValues": { "type": "boolean", diff --git a/tests/Configuration/EditorAddRule.Tests.ps1 b/tests/Configuration/EditorAddRule.Tests.ps1 deleted file mode 100644 index 9b4d0e4..0000000 --- a/tests/Configuration/EditorAddRule.Tests.ps1 +++ /dev/null @@ -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 - } - } -} diff --git a/tests/Configuration/EditorDeleteRule.Tests.ps1 b/tests/Configuration/EditorDeleteRule.Tests.ps1 deleted file mode 100644 index 04faa0f..0000000 --- a/tests/Configuration/EditorDeleteRule.Tests.ps1 +++ /dev/null @@ -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 - } - } -} diff --git a/tests/Configuration/EditorEditRule.Tests.ps1 b/tests/Configuration/EditorEditRule.Tests.ps1 deleted file mode 100644 index 64087c6..0000000 --- a/tests/Configuration/EditorEditRule.Tests.ps1 +++ /dev/null @@ -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 - } - } -} diff --git a/tests/Configuration/EditorStructuralEdits.Tests.ps1 b/tests/Configuration/EditorStructuralEdits.Tests.ps1 deleted file mode 100644 index 0672185..0000000 --- a/tests/Configuration/EditorStructuralEdits.Tests.ps1 +++ /dev/null @@ -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 - } -} diff --git a/tests/Configuration/ExitCodes.Tests.ps1 b/tests/Configuration/ExitCodes.Tests.ps1 deleted file mode 100644 index 9416f9f..0000000 --- a/tests/Configuration/ExitCodes.Tests.ps1 +++ /dev/null @@ -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 $_ } - } -} diff --git a/tests/Configuration/LayerOrdering.Tests.ps1 b/tests/Configuration/LayerOrdering.Tests.ps1 deleted file mode 100644 index f1babd2..0000000 --- a/tests/Configuration/LayerOrdering.Tests.ps1 +++ /dev/null @@ -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 - } -} diff --git a/tests/Configuration/NonInteractive.Tests.ps1 b/tests/Configuration/NonInteractive.Tests.ps1 deleted file mode 100644 index e8dc143..0000000 --- a/tests/Configuration/NonInteractive.Tests.ps1 +++ /dev/null @@ -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' - } -} diff --git a/tests/Configuration/Safety.Tests.ps1 b/tests/Configuration/Safety.Tests.ps1 deleted file mode 100644 index 3940b45..0000000 --- a/tests/Configuration/Safety.Tests.ps1 +++ /dev/null @@ -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' - } - } - } -} diff --git a/tests/Configuration/Semantic.Tests.ps1 b/tests/Configuration/Semantic.Tests.ps1 deleted file mode 100644 index c787b57..0000000 --- a/tests/Configuration/Semantic.Tests.ps1 +++ /dev/null @@ -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 - } -} diff --git a/tests/Configuration/TestJsonBehaviour.Tests.ps1 b/tests/Configuration/TestJsonBehaviour.Tests.ps1 deleted file mode 100644 index bdd649c..0000000 --- a/tests/Configuration/TestJsonBehaviour.Tests.ps1 +++ /dev/null @@ -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 } - } -} diff --git a/tests/Configuration/TraceAcknowledgement.Tests.ps1 b/tests/Configuration/TraceAcknowledgement.Tests.ps1 deleted file mode 100644 index 1c9e47f..0000000 --- a/tests/Configuration/TraceAcknowledgement.Tests.ps1 +++ /dev/null @@ -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 = ''; 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' - } -} diff --git a/tests/PesterConfiguration.ps1 b/tests/PesterConfiguration.ps1 deleted file mode 100644 index e1dac02..0000000 --- a/tests/PesterConfiguration.ps1 +++ /dev/null @@ -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 diff --git a/tests/RuleEngine/Composition.Tests.ps1 b/tests/RuleEngine/Composition.Tests.ps1 deleted file mode 100644 index 47ed37f..0000000 --- a/tests/RuleEngine/Composition.Tests.ps1 +++ /dev/null @@ -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 - } -} diff --git a/tests/RuleEngine/Determinism.Tests.ps1 b/tests/RuleEngine/Determinism.Tests.ps1 deleted file mode 100644 index 5e1563d..0000000 --- a/tests/RuleEngine/Determinism.Tests.ps1 +++ /dev/null @@ -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 - } -} diff --git a/tests/RuleEngine/EvaluationError.Tests.ps1 b/tests/RuleEngine/EvaluationError.Tests.ps1 deleted file mode 100644 index 93b8a70..0000000 --- a/tests/RuleEngine/EvaluationError.Tests.ps1 +++ /dev/null @@ -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' - } -} diff --git a/tests/RuleEngine/Operators.Tests.ps1 b/tests/RuleEngine/Operators.Tests.ps1 deleted file mode 100644 index d68f939..0000000 --- a/tests/RuleEngine/Operators.Tests.ps1 +++ /dev/null @@ -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 @('') -RolesRetrieved) - Test-Op @{ type = 'role'; operator = 'memberOf'; roleIds = @('') } $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 @('') -RolesRetrieved) - Test-Op @{ type = 'role'; operator = 'memberOf'; roleIds = @('') } $u 'Direct' | Should -Be 'True' - } - } -} diff --git a/tests/RuleEngine/Ordering.Tests.ps1 b/tests/RuleEngine/Ordering.Tests.ps1 deleted file mode 100644 index d8654bd..0000000 --- a/tests/RuleEngine/Ordering.Tests.ps1 +++ /dev/null @@ -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 - } -} diff --git a/tests/RuleEngine/Unclassified.Tests.ps1 b/tests/RuleEngine/Unclassified.Tests.ps1 deleted file mode 100644 index 211cf06..0000000 --- a/tests/RuleEngine/Unclassified.Tests.ps1 +++ /dev/null @@ -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' - } -} diff --git a/tests/RuleEngine/UnknownNotFalse.Tests.ps1 b/tests/RuleEngine/UnknownNotFalse.Tests.ps1 deleted file mode 100644 index 5fe1ea4..0000000 --- a/tests/RuleEngine/UnknownNotFalse.Tests.ps1 +++ /dev/null @@ -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 -> True - notMemberOf -> 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 = @('') - }) | 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' - } -} diff --git a/tests/RuleEngine/UnknownPropagation.Tests.ps1 b/tests/RuleEngine/UnknownPropagation.Tests.ps1 deleted file mode 100644 index 400b568..0000000 --- a/tests/RuleEngine/UnknownPropagation.Tests.ps1 +++ /dev/null @@ -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' - } - } -} diff --git a/tests/Safety/Idempotence.Tests.ps1 b/tests/Safety/Idempotence.Tests.ps1 deleted file mode 100644 index 0b94499..0000000 --- a/tests/Safety/Idempotence.Tests.ps1 +++ /dev/null @@ -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__' - $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 - } -} diff --git a/tests/Safety/ShouldProcessGate.Tests.ps1 b/tests/Safety/ShouldProcessGate.Tests.ps1 deleted file mode 100644 index 9edb66b..0000000 --- a/tests/Safety/ShouldProcessGate.Tests.ps1 +++ /dev/null @@ -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__' - $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' - } -} diff --git a/tests/Safety/WhatIfZeroWrites.Tests.ps1 b/tests/Safety/WhatIfZeroWrites.Tests.ps1 deleted file mode 100644 index cb2fbda..0000000 --- a/tests/Safety/WhatIfZeroWrites.Tests.ps1 +++ /dev/null @@ -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__' - - $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 - } -} diff --git a/tests/Safety/WriteBody.Tests.ps1 b/tests/Safety/WriteBody.Tests.ps1 deleted file mode 100644 index 4e48beb..0000000 --- a/tests/Safety/WriteBody.Tests.ps1 +++ /dev/null @@ -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__' - $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 - } -} diff --git a/tests/Safety/WriteBodyRejection.Tests.ps1 b/tests/Safety/WriteBodyRejection.Tests.ps1 deleted file mode 100644 index 95ed6e7..0000000 --- a/tests/Safety/WriteBodyRejection.Tests.ps1 +++ /dev/null @@ -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__' - $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__' -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__') } | - 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' - } -} diff --git a/tests/Safety/WriteGate.Tests.ps1 b/tests/Safety/WriteGate.Tests.ps1 deleted file mode 100644 index cb55807..0000000 --- a/tests/Safety/WriteGate.Tests.ps1 +++ /dev/null @@ -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__' - - 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__') - - $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 - } -} diff --git a/tests/Test-EnginePurity.ps1 b/tests/Test-EnginePurity.ps1 deleted file mode 100644 index d7b777e..0000000 --- a/tests/Test-EnginePurity.ps1 +++ /dev/null @@ -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 diff --git a/tests/Test-Sanitization.ps1 b/tests/Test-Sanitization.ps1 deleted file mode 100644 index 22e9e4c..0000000 --- a/tests/Test-Sanitization.ps1 +++ /dev/null @@ -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 @ 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 diff --git a/tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1 b/tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1 deleted file mode 100644 index 854c99e..0000000 --- a/tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1 +++ /dev/null @@ -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__' - approvedWritableAttributes = @('extension__') - 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__') -$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 = @('') } -$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__') -$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) diff --git a/tests/TestData/InvalidConfigs/PE-SAF-001-blank-target-production.json b/tests/TestData/InvalidConfigs/PE-SAF-001-blank-target-production.json deleted file mode 100644 index ade4af6..0000000 --- a/tests/TestData/InvalidConfigs/PE-SAF-001-blank-target-production.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "targetAttribute": "", - "maxConditionDepth": 5, - "approvedWritableAttributes": [ - "extension__" - ], - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-002-unsupported-writable-attribute.json b/tests/TestData/InvalidConfigs/PE-SAF-002-unsupported-writable-attribute.json deleted file mode 100644 index 1eef46a..0000000 --- a/tests/TestData/InvalidConfigs/PE-SAF-002-unsupported-writable-attribute.json +++ /dev/null @@ -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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-003-group-rules-without-group-retrieval.json b/tests/TestData/InvalidConfigs/PE-SAF-003-group-rules-without-group-retrieval.json deleted file mode 100644 index 6041ca9..0000000 --- a/tests/TestData/InvalidConfigs/PE-SAF-003-group-rules-without-group-retrieval.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-004-version-downgrade.json b/tests/TestData/InvalidConfigs/PE-SAF-004-version-downgrade.json deleted file mode 100644 index daaf94b..0000000 --- a/tests/TestData/InvalidConfigs/PE-SAF-004-version-downgrade.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "0.9.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-005-rule-removed-without-version-change.json b/tests/TestData/InvalidConfigs/PE-SAF-005-rule-removed-without-version-change.json deleted file mode 100644 index 95a9149..0000000 --- a/tests/TestData/InvalidConfigs/PE-SAF-005-rule-removed-without-version-change.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-006-tracing-without-acknowledgement.json b/tests/TestData/InvalidConfigs/PE-SAF-006-tracing-without-acknowledgement.json deleted file mode 100644 index 74198b5..0000000 --- a/tests/TestData/InvalidConfigs/PE-SAF-006-tracing-without-acknowledgement.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-001-duplicate-rule-id.json b/tests/TestData/InvalidConfigs/PE-SEM-001-duplicate-rule-id.json deleted file mode 100644 index 56274fa..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-001-duplicate-rule-id.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-002-duplicate-priority.json b/tests/TestData/InvalidConfigs/PE-SEM-002-duplicate-priority.json deleted file mode 100644 index 1495a0c..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-002-duplicate-priority.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-003-no-enabled-rules.json b/tests/TestData/InvalidConfigs/PE-SEM-003-no-enabled-rules.json deleted file mode 100644 index 25f582d..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-003-no-enabled-rules.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-004-blank-target-attribute.json b/tests/TestData/InvalidConfigs/PE-SEM-004-blank-target-attribute.json deleted file mode 100644 index 57adaa7..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-004-blank-target-attribute.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "targetAttribute": " ", - "maxConditionDepth": 5, - "approvedWritableAttributes": [ - "extension__" - ], - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-005-target-not-approved.json b/tests/TestData/InvalidConfigs/PE-SEM-005-target-not-approved.json deleted file mode 100644 index c214cbe..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-005-target-not-approved.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "targetAttribute": "extension__", - "maxConditionDepth": 5, - "approvedWritableAttributes": [ - "extension__" - ], - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-006-unavailable-data-source.json b/tests/TestData/InvalidConfigs/PE-SEM-006-unavailable-data-source.json deleted file mode 100644 index f227381..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-006-unavailable-data-source.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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": [ - "" - ] - } - ] - }, - "priority": 30, - "enabled": true, - "name": "Tier 0 administrators" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-007-memberof-without-groups.json b/tests/TestData/InvalidConfigs/PE-SEM-007-memberof-without-groups.json deleted file mode 100644 index dec4a2e..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-007-memberof-without-groups.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-008-in-without-values.json b/tests/TestData/InvalidConfigs/PE-SEM-008-in-without-values.json deleted file mode 100644 index c6bfe3e..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-008-in-without-values.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-009-isnull-with-value.json b/tests/TestData/InvalidConfigs/PE-SEM-009-isnull-with-value.json deleted file mode 100644 index 950d2ce..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-009-isnull-with-value.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-010-undeclared-persona.json b/tests/TestData/InvalidConfigs/PE-SEM-010-undeclared-persona.json deleted file mode 100644 index a34e243..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-010-undeclared-persona.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-011-unclassified-as-persona.json b/tests/TestData/InvalidConfigs/PE-SEM-011-unclassified-as-persona.json deleted file mode 100644 index 35782d2..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-011-unclassified-as-persona.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-012-depth-over-configured-maximum.json b/tests/TestData/InvalidConfigs/PE-SEM-012-depth-over-configured-maximum.json deleted file mode 100644 index ce81eaf..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-012-depth-over-configured-maximum.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "targetAttribute": "extension__", - "maxConditionDepth": 2, - "approvedWritableAttributes": [ - "extension__" - ], - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-013-depth-over-hard-ceiling.json b/tests/TestData/InvalidConfigs/PE-SEM-013-depth-over-hard-ceiling.json deleted file mode 100644 index 3e75745..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-013-depth-over-hard-ceiling.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "targetAttribute": "extension__", - "maxConditionDepth": 25, - "approvedWritableAttributes": [ - "extension__" - ], - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-014-mode-not-enabled-globally.json b/tests/TestData/InvalidConfigs/PE-SEM-014-mode-not-enabled-globally.json deleted file mode 100644 index d0b3799..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-014-mode-not-enabled-globally.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-015-unsupported-property.json b/tests/TestData/InvalidConfigs/PE-SEM-015-unsupported-property.json deleted file mode 100644 index 5c601a8..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-015-unsupported-property.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-016-invalid-regex.json b/tests/TestData/InvalidConfigs/PE-SEM-016-invalid-regex.json deleted file mode 100644 index d16a17e..0000000 --- a/tests/TestData/InvalidConfigs/PE-SEM-016-invalid-regex.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/InvalidConfigs/baseline-deployed.json b/tests/TestData/InvalidConfigs/baseline-deployed.json deleted file mode 100644 index c0be146..0000000 --- a/tests/TestData/InvalidConfigs/baseline-deployed.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "configVersion": "1.0.0", - "engine": { - "maxConditionDepth": 5, - "defaultMembershipMode": "direct", - "targetAttribute": "extension__", - "summaryInterval": 25, - "approvedWritableAttributes": [ - "extension__" - ] - }, - "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" - } - ] -} diff --git a/tests/TestData/Memberships/memberships.json b/tests/TestData/Memberships/memberships.json deleted file mode 100644 index 2da0fe4..0000000 --- a/tests/TestData/Memberships/memberships.json +++ /dev/null @@ -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": [ - "" - ], - "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" - } - ] -} diff --git a/tests/TestData/Users/users.json b/tests/TestData/Users/users.json deleted file mode 100644 index 42cd38d..0000000 --- a/tests/TestData/Users/users.json +++ /dev/null @@ -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": "", - "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": "", - "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": "", - "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": "", - "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": "", - "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": "", - "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": "", - "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": "", - "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": "", - "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": "", - "Department": "Finance" - }, - "storedPersona": "Tier0-Admin" - } - ] -} diff --git a/tests/TestHelpers.ps1 b/tests/TestHelpers.ps1 deleted file mode 100644 index 7e26ee5..0000000 --- a/tests/TestHelpers.ps1 +++ /dev/null @@ -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__' - approvedWritableAttributes = @('extension__') - 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 = '', - [string] $TargetAttribute = 'extension__', - [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__' - ) - - $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__', - [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 = '' - 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) - }) -} diff --git a/tests/Unit/AuditCompleteness.Tests.ps1 b/tests/Unit/AuditCompleteness.Tests.ps1 deleted file mode 100644 index 921c18c..0000000 --- a/tests/Unit/AuditCompleteness.Tests.ps1 +++ /dev/null @@ -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__' - $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 - } -} diff --git a/tests/Unit/AuditRecordShape.Tests.ps1 b/tests/Unit/AuditRecordShape.Tests.ps1 deleted file mode 100644 index a1900ba..0000000 --- a/tests/Unit/AuditRecordShape.Tests.ps1 +++ /dev/null @@ -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 } - } -} diff --git a/tests/Unit/AuditRedaction.Tests.ps1 b/tests/Unit/AuditRedaction.Tests.ps1 deleted file mode 100644 index dd15ee4..0000000 --- a/tests/Unit/AuditRedaction.Tests.ps1 +++ /dev/null @@ -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__' - - # 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 - } -} diff --git a/tests/Unit/ConditionTrace.Tests.ps1 b/tests/Unit/ConditionTrace.Tests.ps1 deleted file mode 100644 index 3e1af51..0000000 --- a/tests/Unit/ConditionTrace.Tests.ps1 +++ /dev/null @@ -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__' - - $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 - } - } -} diff --git a/tests/Unit/ExitCodes.Tests.ps1 b/tests/Unit/ExitCodes.Tests.ps1 deleted file mode 100644 index a1d0afd..0000000 --- a/tests/Unit/ExitCodes.Tests.ps1 +++ /dev/null @@ -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__' - $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' - } -} diff --git a/tests/Unit/OutcomeExclusivity.Tests.ps1 b/tests/Unit/OutcomeExclusivity.Tests.ps1 deleted file mode 100644 index d15f652..0000000 --- a/tests/Unit/OutcomeExclusivity.Tests.ps1 +++ /dev/null @@ -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__' - - # 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' - } -} diff --git a/tests/Unit/Pagination.Tests.ps1 b/tests/Unit/Pagination.Tests.ps1 deleted file mode 100644 index 88dd95a..0000000 --- a/tests/Unit/Pagination.Tests.ps1 +++ /dev/null @@ -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__' -} - -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 - } -} diff --git a/tests/Unit/Reconciliation.Tests.ps1 b/tests/Unit/Reconciliation.Tests.ps1 deleted file mode 100644 index 91da753..0000000 --- a/tests/Unit/Reconciliation.Tests.ps1 +++ /dev/null @@ -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__' - $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 - } -} diff --git a/tests/Unit/RecordContracts.Tests.ps1 b/tests/Unit/RecordContracts.Tests.ps1 deleted file mode 100644 index 4e1e53e..0000000 --- a/tests/Unit/RecordContracts.Tests.ps1 +++ /dev/null @@ -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 - } -} diff --git a/tests/Unit/RetryPolicy.Tests.ps1 b/tests/Unit/RetryPolicy.Tests.ps1 deleted file mode 100644 index ddd34c7..0000000 --- a/tests/Unit/RetryPolicy.Tests.ps1 +++ /dev/null @@ -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' - } -} diff --git a/tests/Unit/SummaryInterval.Tests.ps1 b/tests/Unit/SummaryInterval.Tests.ps1 deleted file mode 100644 index 949e1af..0000000 --- a/tests/Unit/SummaryInterval.Tests.ps1 +++ /dev/null @@ -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__' - $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 - } -}