config editor
This commit is contained in:
@@ -306,17 +306,206 @@ function Save-PersonaConfiguration {
|
|||||||
$true
|
$true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-PersonaCandidateEdit {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Applies a structural edit to a cloned document and re-validates it (FR-030).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Deep-clones Document via a JSON round trip (nested objects are reference
|
||||||
|
types, so mutating Document directly would leave a half-applied edit in
|
||||||
|
place if validation then rejected it), runs Apply against the clone, and
|
||||||
|
validates the clone in full - the identical check the `[V]` command and
|
||||||
|
`Save-PersonaConfiguration` use, so add/edit/delete can never be held to a
|
||||||
|
looser standard than a hand edit.
|
||||||
|
|
||||||
|
The document entering the interactive editor is already Error-free, so a
|
||||||
|
clone that fails here failed because of Apply, not because of pre-existing
|
||||||
|
state.
|
||||||
|
|
||||||
|
.PARAMETER Document
|
||||||
|
The current in-memory configuration document.
|
||||||
|
|
||||||
|
.PARAMETER Apply
|
||||||
|
A scriptblock taking the clone as its first parameter, followed by whatever
|
||||||
|
ApplyArgs supplies, and mutating the clone in place. Values are passed as
|
||||||
|
explicit arguments rather than closed over from the caller's scope: `&`
|
||||||
|
invocation runs a scriptblock in a new child scope of wherever it is invoked
|
||||||
|
(here, inside this function) rather than of the scope where the scriptblock
|
||||||
|
literal was written, so a bare `{ $doc.rules = ... $id ... }` would see $id as
|
||||||
|
unbound. Explicit parameters sidestep that entirely.
|
||||||
|
|
||||||
|
.PARAMETER ApplyArgs
|
||||||
|
Positional arguments passed to Apply after the clone.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
pscustomobject with Applied (bool), Document (the clone if Applied, otherwise
|
||||||
|
the original Document, unchanged), and Result (the validation result).
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [object] $Document,
|
||||||
|
[Parameter(Mandatory)] [scriptblock] $Apply,
|
||||||
|
[object[]] $ApplyArgs = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
$clone = $Document | ConvertTo-Json -Depth 32 | ConvertFrom-Json -Depth 32
|
||||||
|
& $Apply $clone @ApplyArgs
|
||||||
|
|
||||||
|
$temp = [System.IO.Path]::GetTempFileName()
|
||||||
|
try {
|
||||||
|
Set-Content -LiteralPath $temp -Value ($clone | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
||||||
|
$check = Invoke-ConfigurationValidation -Path $temp
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
Applied = $check.Result.IsValid
|
||||||
|
Document = $check.Result.IsValid ? $clone : $Document
|
||||||
|
Result = $check.Result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-PersonaConditionPath {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Parses an operator-typed path like "1.0" into the int[] Get-PersonaConditionNode expects.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Blank input, or the literal word "root", addresses the rule's match group
|
||||||
|
itself.
|
||||||
|
#>
|
||||||
|
param([string] $Text)
|
||||||
|
|
||||||
|
$trimmed = ([string]$Text).Trim()
|
||||||
|
if ([string]::IsNullOrEmpty($trimmed) -or $trimmed -eq 'root') { return , @() }
|
||||||
|
|
||||||
|
$indices = foreach ($part in ($trimmed -split '[.\s]+' | Where-Object { $_ -ne '' })) {
|
||||||
|
$value = 0
|
||||||
|
if (-not [int]::TryParse($part, [ref] $value)) {
|
||||||
|
throw "'$part' is not a valid path segment - use dot-separated indices, for example '1.0'."
|
||||||
|
}
|
||||||
|
$value
|
||||||
|
}
|
||||||
|
|
||||||
|
, @($indices)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-PersonaConditionLeafFields {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Prompts for one leaf condition's fields, in the shape Set-PersonaConditionLeaf expects.
|
||||||
|
#>
|
||||||
|
Write-Host ' Condition type: [P]roperty [M]embership [R]ole' -ForegroundColor DarkGray
|
||||||
|
$typeChoice = (Read-Host ' Type').Trim().ToUpperInvariant()
|
||||||
|
$type = switch ($typeChoice) { 'P' { 'property' } 'M' { 'membership' } 'R' { 'role' } default { $null } }
|
||||||
|
if (-not $type) { throw "Unrecognized condition type '$typeChoice'." }
|
||||||
|
|
||||||
|
Write-Host ' Operators: equals notEquals contains notContains startsWith endsWith matchesRegex in notIn isNull isNotNull memberOf notMemberOf' -ForegroundColor DarkGray
|
||||||
|
$operator = (Read-Host ' Operator').Trim()
|
||||||
|
|
||||||
|
$fields = @{ type = $type; operator = $operator }
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
'property' { $fields['property'] = (Read-Host ' Property name').Trim() }
|
||||||
|
'membership' {
|
||||||
|
$ids = (Read-Host ' Group Object IDs (comma-separated)')
|
||||||
|
$fields['groupObjectIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||||
|
$mode = (Read-Host ' Membership mode (direct/transitive, blank = configuration default)').Trim()
|
||||||
|
if ($mode) { $fields['membershipMode'] = $mode }
|
||||||
|
}
|
||||||
|
'role' {
|
||||||
|
$ids = (Read-Host ' Role IDs (comma-separated)')
|
||||||
|
$fields['roleIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operator -in @('in', 'notIn')) {
|
||||||
|
$values = (Read-Host ' Values (comma-separated)')
|
||||||
|
$fields['values'] = @($values -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||||
|
}
|
||||||
|
elseif ($operator -notin @('isNull', 'isNotNull', 'memberOf', 'notMemberOf')) {
|
||||||
|
$fields['value'] = Read-Host ' Comparison value'
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-PersonaConditionNode {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Interactively builds one condition or nested group (FR-027, FR-028).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Recurses for a group's children. Nesting depth is not limited here - see
|
||||||
|
Test-PersonaCandidateEdit and Add-PersonaConditionNode's doc comment for why
|
||||||
|
depth is enforced by the real validator instead of a second, local count.
|
||||||
|
#>
|
||||||
|
param([int] $Depth = 1)
|
||||||
|
|
||||||
|
$indent = ' ' * $Depth
|
||||||
|
Write-Host ("{0}[C]ondition or [G]roup?" -f $indent) -ForegroundColor DarkGray
|
||||||
|
$kind = (Read-Host "$indent Kind").Trim().ToUpperInvariant()
|
||||||
|
|
||||||
|
if ($kind -eq 'G') {
|
||||||
|
$operator = (Read-Host "$indent Group operator (all/any)").Trim().ToLowerInvariant()
|
||||||
|
if ($operator -notin @('all', 'any')) { throw "Group operator must be 'all' or 'any'." }
|
||||||
|
|
||||||
|
$children = [System.Collections.Generic.List[object]]::new()
|
||||||
|
do {
|
||||||
|
$children.Add((Read-PersonaConditionNode -Depth ($Depth + 1)))
|
||||||
|
$more = (Read-Host "$indent Add another condition to this group? (y/N)").Trim()
|
||||||
|
} while ($more.ToLowerInvariant() -eq 'y')
|
||||||
|
|
||||||
|
return [pscustomobject]@{ operator = $operator; conditions = $children.ToArray() }
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject](Read-PersonaConditionLeafFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-PersonaRuleFields {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Prompts for a new rule's top-level fields (RE-001), excluding its match tree.
|
||||||
|
#>
|
||||||
|
$id = (Read-Host 'Rule id').Trim()
|
||||||
|
$name = (Read-Host 'Rule name').Trim()
|
||||||
|
$description = (Read-Host 'Description').Trim()
|
||||||
|
|
||||||
|
$priority = 0
|
||||||
|
if (-not [int]::TryParse((Read-Host 'Priority (integer)'), [ref] $priority)) {
|
||||||
|
throw 'Priority must be an integer.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$persona = (Read-Host 'Persona').Trim()
|
||||||
|
$enabled = (Read-Host 'Enabled? (Y/n)').Trim().ToLowerInvariant() -ne 'n'
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
id = $id; name = $name; description = $description
|
||||||
|
enabled = $enabled; priority = $priority; persona = $persona
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-InteractiveEditor {
|
function Invoke-InteractiveEditor {
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
The interactive loop (FR-023).
|
The interactive loop (FR-023).
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Deliberately small. It toggles rules, adjusts priorities, re-validates, tests
|
Toggles rules, adjusts priorities, adds/edits/deletes whole rules including
|
||||||
against fixtures, and saves. It does not attempt to be a JSON editor - rule
|
their condition trees (FR-027 - FR-030), re-validates, tests against
|
||||||
authoring belongs in a text editor with schema completion, and a
|
fixtures, and saves.
|
||||||
half-featured structural editor would only add ways to corrupt a file that
|
|
||||||
is already under version control.
|
Every structural edit (add/edit/delete) goes through Test-PersonaCandidateEdit,
|
||||||
|
which applies it to a clone and re-runs full validation before it is allowed
|
||||||
|
to reach $Document. The live document entering this loop is already
|
||||||
|
Error-free - the main script exits before this function is ever called
|
||||||
|
otherwise - so a clone that fails validation failed because of the edit just
|
||||||
|
applied, and is discarded rather than committed. This is what makes
|
||||||
|
structural editing safe to add at all: the editor can never produce a worse
|
||||||
|
document than a careful hand edit would, because it runs the same four
|
||||||
|
validation layers a hand edit is checked against at save time.
|
||||||
#>
|
#>
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)] [object] $Document,
|
[Parameter(Mandatory)] [object] $Document,
|
||||||
@@ -333,6 +522,7 @@ function Invoke-InteractiveEditor {
|
|||||||
Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count)
|
Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count)
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host ' [L] list rules [T] toggle a rule [P] change a priority'
|
Write-Host ' [L] list rules [T] toggle a rule [P] change a priority'
|
||||||
|
Write-Host ' [A] add a rule [E] edit a rule [D] delete a rule'
|
||||||
Write-Host ' [V] re-validate [R] run rule test [S] save'
|
Write-Host ' [V] re-validate [R] run rule test [S] save'
|
||||||
Write-Host ' [Q] quit'
|
Write-Host ' [Q] quit'
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
@@ -378,6 +568,200 @@ function Invoke-InteractiveEditor {
|
|||||||
Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green
|
Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
|
'A' {
|
||||||
|
# FR-027. The candidate rule is built entirely from prompts before
|
||||||
|
# Test-PersonaCandidateEdit ever sees it, so a collision or a
|
||||||
|
# validation failure is reported once, against the whole rule, rather
|
||||||
|
# than mid-build against a rule that only half exists yet.
|
||||||
|
try {
|
||||||
|
$fields = Read-PersonaRuleFields
|
||||||
|
|
||||||
|
Write-Host 'Build the condition tree for this rule (the root group):' -ForegroundColor Cyan
|
||||||
|
$rootOperator = (Read-Host 'Root operator (all/any)').Trim().ToLowerInvariant()
|
||||||
|
if ($rootOperator -notin @('all', 'any')) { throw "Root operator must be 'all' or 'any'." }
|
||||||
|
|
||||||
|
$rootChildren = [System.Collections.Generic.List[object]]::new()
|
||||||
|
do {
|
||||||
|
$rootChildren.Add((Read-PersonaConditionNode -Depth 1))
|
||||||
|
$more = (Read-Host 'Add another top-level condition? (y/N)').Trim()
|
||||||
|
} while ($more.ToLowerInvariant() -eq 'y')
|
||||||
|
|
||||||
|
$newRule = [pscustomobject]@{
|
||||||
|
id = $fields.id
|
||||||
|
name = $fields.name
|
||||||
|
description = $fields.description
|
||||||
|
enabled = $fields.enabled
|
||||||
|
priority = $fields.priority
|
||||||
|
persona = $fields.persona
|
||||||
|
match = [pscustomobject]@{ operator = $rootOperator; conditions = $rootChildren.ToArray() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($newRule) -Apply {
|
||||||
|
param($doc, $rule)
|
||||||
|
$doc.rules = Add-PersonaConfigRule -Rules $doc.rules -Rule $rule
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attempt.Applied) {
|
||||||
|
$Document = $attempt.Document
|
||||||
|
$dirty = $true
|
||||||
|
Write-Host "Rule $($newRule.id) added." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host 'Add rejected: the resulting configuration would be invalid.' -ForegroundColor Red
|
||||||
|
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "Add cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
'E' {
|
||||||
|
# FR-028. One structural operation per pass through this case - the
|
||||||
|
# operator repeats [E] to make further changes, which keeps each
|
||||||
|
# Test-PersonaCandidateEdit call scoped to one edit and one finding
|
||||||
|
# set, rather than a batch where a rejection leaves it unclear which
|
||||||
|
# of several changes caused it.
|
||||||
|
try {
|
||||||
|
$id = (Read-Host 'Rule id to edit').Trim()
|
||||||
|
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
||||||
|
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
||||||
|
|
||||||
|
Write-Host ("Editing rule {0} ({1})." -f $rule.id, $rule.name) -ForegroundColor Cyan
|
||||||
|
Write-Host ' [F] change a top-level field [C] add a condition/group'
|
||||||
|
Write-Host ' [E] edit a leaf condition [X] remove a condition/group'
|
||||||
|
Write-Host ' [B] back to the main menu, no change'
|
||||||
|
|
||||||
|
$subChoice = (Read-Host ' Edit action').Trim().ToUpperInvariant()
|
||||||
|
|
||||||
|
$attempt = switch ($subChoice) {
|
||||||
|
|
||||||
|
'F' {
|
||||||
|
$field = (Read-Host ' Field (name/description/priority/persona/enabled)').Trim().ToLowerInvariant()
|
||||||
|
$value = Read-Host ' New value'
|
||||||
|
|
||||||
|
Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id, $field, $value) -Apply {
|
||||||
|
param($doc, $ruleId, $fieldName, $newValue)
|
||||||
|
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
||||||
|
switch ($fieldName) {
|
||||||
|
'priority' { $target.priority = [int]$newValue }
|
||||||
|
'enabled' { $target.enabled = $newValue.Trim().ToLowerInvariant() -in @('y', 'yes', 'true', '1') }
|
||||||
|
'name' { $target.name = $newValue }
|
||||||
|
'description' { $target.description = $newValue }
|
||||||
|
'persona' { $target.persona = $newValue }
|
||||||
|
default { throw "Unrecognized field '$fieldName'." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
'C' {
|
||||||
|
$parentPath = ConvertTo-PersonaConditionPath -Text (Read-Host ' Parent path (blank = root, e.g. "1.0")')
|
||||||
|
$newNode = Read-PersonaConditionNode -Depth ($parentPath.Count + 1)
|
||||||
|
|
||||||
|
# ApplyArgs is built with .Add() rather than @(...), because an
|
||||||
|
# int[] placed inside a `@()` array literal is unrolled into the
|
||||||
|
# outer array (the same gotcha Get-CapturedAuditRecord's comma
|
||||||
|
# idiom guards against elsewhere) - @($id, $parentPath, $newNode)
|
||||||
|
# would silently flatten a two-segment path into two extra
|
||||||
|
# positional arguments instead of passing it as one array.
|
||||||
|
$args = [System.Collections.Generic.List[object]]::new()
|
||||||
|
$args.Add($id); $args.Add($parentPath); $args.Add($newNode)
|
||||||
|
|
||||||
|
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
|
||||||
|
param($doc, $ruleId, $parentPathArg, $node)
|
||||||
|
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
||||||
|
Add-PersonaConditionNode -Group $target.match -ParentPath $parentPathArg -Node $node | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
'E' {
|
||||||
|
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Leaf condition path (e.g. "1.0")')
|
||||||
|
if ($path.Count -eq 0) { throw 'The root is a group, not a leaf condition - navigate to a leaf path.' }
|
||||||
|
$fields = Read-PersonaConditionLeafFields
|
||||||
|
|
||||||
|
$args = [System.Collections.Generic.List[object]]::new()
|
||||||
|
$args.Add($id); $args.Add($path); $args.Add($fields)
|
||||||
|
|
||||||
|
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
|
||||||
|
param($doc, $ruleId, $pathArg, $fieldsArg)
|
||||||
|
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
||||||
|
$leaf = Get-PersonaConditionNode -Group $target.match -Path $pathArg
|
||||||
|
Set-PersonaConditionLeaf -Node $leaf -Fields $fieldsArg | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
'X' {
|
||||||
|
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Condition/group path to remove (e.g. "1.0")')
|
||||||
|
|
||||||
|
$args = [System.Collections.Generic.List[object]]::new()
|
||||||
|
$args.Add($id); $args.Add($path)
|
||||||
|
|
||||||
|
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
|
||||||
|
param($doc, $ruleId, $pathArg)
|
||||||
|
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
||||||
|
Remove-PersonaConditionNode -Group $target.match -Path $pathArg | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
'B' { $null }
|
||||||
|
|
||||||
|
default { Write-Host 'Unrecognized edit action.' -ForegroundColor Yellow; $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $attempt) {
|
||||||
|
if ($attempt.Applied) {
|
||||||
|
$Document = $attempt.Document
|
||||||
|
$dirty = $true
|
||||||
|
Write-Host "Rule $id updated." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host 'Edit rejected: the resulting configuration would be invalid.' -ForegroundColor Red
|
||||||
|
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "Edit cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
'D' {
|
||||||
|
# FR-029. Confirmation names id, name, and priority before anything is
|
||||||
|
# touched, matching the acceptance criterion literally rather than a
|
||||||
|
# generic "are you sure?".
|
||||||
|
try {
|
||||||
|
$id = (Read-Host 'Rule id to delete').Trim()
|
||||||
|
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
||||||
|
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
||||||
|
|
||||||
|
Write-Host ("About to delete rule {0} '{1}' priority {2}." -f $rule.id, $rule.name, $rule.priority) -ForegroundColor Yellow
|
||||||
|
$confirm = (Read-Host 'Delete this rule? (y/N)').Trim().ToLowerInvariant()
|
||||||
|
|
||||||
|
if ($confirm -ne 'y') {
|
||||||
|
Write-Host 'Delete cancelled.' -ForegroundColor Yellow
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id) -Apply {
|
||||||
|
param($doc, $ruleId)
|
||||||
|
$doc.rules = Remove-PersonaConfigRule -Rules $doc.rules -RuleId $ruleId
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attempt.Applied) {
|
||||||
|
$Document = $attempt.Document
|
||||||
|
$dirty = $true
|
||||||
|
Write-Host "Rule $id deleted." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host 'Delete rejected: the resulting configuration would be invalid.' -ForegroundColor Red
|
||||||
|
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "Delete cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
'V' {
|
'V' {
|
||||||
$temp = [System.IO.Path]::GetTempFileName()
|
$temp = [System.IO.Path]::GetTempFileName()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -73,6 +73,29 @@ Stability matters: pipelines and runbooks will match on these codes.
|
|||||||
| `3` | Configuration file not found or unreadable |
|
| `3` | Configuration file not found or unreadable |
|
||||||
| `4` | Schema file not found or itself invalid |
|
| `4` | Schema file not found or itself invalid |
|
||||||
|
|
||||||
|
## Interactive editor commands (FR-023, FR-027 – FR-030)
|
||||||
|
|
||||||
|
The interactive loop (entered when neither `-ValidateOnly` nor `-NonInteractive` is set) supports:
|
||||||
|
|
||||||
|
| Command | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| List rules | Show every rule's priority, ID, persona, and enabled state. |
|
||||||
|
| Toggle a rule | Flip `enabled` on an existing rule. |
|
||||||
|
| Change a priority | Set a new numeric priority on an existing rule. |
|
||||||
|
| **Add a rule** | Prompt for every RE-001 field (`id`, `name`, `description`, `priority`, `persona`, `enabled`, and optional fields) and for the condition tree — nested `all`/`any` groups and, per leaf condition, the property/membership source, operator, and comparison value. Reject on the spot if the `id` or `priority` collides with an existing rule (FR-027). |
|
||||||
|
| **Edit a rule** | Select an existing rule by `id`; change any top-level field and/or the condition tree — add, edit, remove, or renest conditions and groups within `MaxConditionDepth` (FR-028). |
|
||||||
|
| **Delete a rule** | Select an existing rule by `id`; show its `id`, `name`, and `priority` and require explicit confirmation before removing it (FR-029). |
|
||||||
|
| Re-validate | Run all four validation layers against the in-memory document, including any unsaved add/edit/delete, and print findings without saving. |
|
||||||
|
| Run rule test | Evaluate the in-memory document (including unsaved structural edits) against `-TestDataPath` fixtures. |
|
||||||
|
| Save | Re-validate, then persist per the save contract below. |
|
||||||
|
| Quit | Warn if there are unsaved changes (including structural edits) before discarding them. |
|
||||||
|
|
||||||
|
All structural edits (add/edit/delete) are applied to the in-memory document only. They are never
|
||||||
|
written to `-ConfigPath` (or `-OutputPath`) until a `Save` re-validates the full document and that
|
||||||
|
validation passes — the same rule that governs field-level edits (FR-030). A depth violation
|
||||||
|
introduced by an add or edit is reported immediately using the same finding the runtime validator
|
||||||
|
would produce, rather than deferred to the next save or re-validate.
|
||||||
|
|
||||||
## Save contract (FR-026)
|
## Save contract (FR-026)
|
||||||
|
|
||||||
1. Re-validate the edited document in full.
|
1. Re-validate the edited document in full.
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ An operator watching a run sees each user's result appear immediately, receives
|
|||||||
|
|
||||||
### User Story 5 - Validate and edit configuration safely (Priority: P2)
|
### User Story 5 - Validate and edit configuration safely (Priority: P2)
|
||||||
|
|
||||||
A configuration owner validates a configuration file, edits rules interactively, tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first.
|
A configuration owner validates a configuration file, adds, edits, and deletes rules interactively — including each rule's condition tree — tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first.
|
||||||
|
|
||||||
**Why this priority**: Makes the configuration-driven model usable and safe in practice, but the engine can be exercised with a hand-authored file first.
|
**Why this priority**: Makes the configuration-driven model usable and safe in practice, but the engine can be exercised with a hand-authored file first.
|
||||||
|
|
||||||
@@ -96,6 +96,13 @@ A configuration owner validates a configuration file, edits rules interactively,
|
|||||||
2. **Given** an edit session with unsaved valid changes, **When** the file is saved over an existing configuration, **Then** a timestamped backup or save-as output is produced first.
|
2. **Given** an edit session with unsaved valid changes, **When** the file is saved over an existing configuration, **Then** a timestamped backup or save-as output is produced first.
|
||||||
3. **Given** synthetic sample users are supplied, **When** rules are tested, **Then** the resulting persona for each sample is reported without any tenant connection.
|
3. **Given** synthetic sample users are supplied, **When** rules are tested, **Then** the resulting persona for each sample is reported without any tenant connection.
|
||||||
4. **Given** a valid configuration, **When** validation runs, **Then** no Error findings are produced and the file is accepted.
|
4. **Given** a valid configuration, **When** validation runs, **Then** no Error findings are produced and the file is accepted.
|
||||||
|
5. **Given** an interactive edit session, **When** the configuration owner adds a new rule, **Then** they are prompted for every required field (`id`, `name`, `description`, `priority`, `persona`, `enabled`) and for the rule's condition tree — including nested `all`/`any` groups and, for each leaf condition, the property or membership source, operator, and comparison value — and the new rule is appended to the in-memory document without being written to disk until save.
|
||||||
|
6. **Given** an interactive edit session, **When** the configuration owner attempts to add a rule using an `id` or `priority` already present in the document, **Then** the attempt is rejected before the rule is added, with a message naming the conflicting rule.
|
||||||
|
7. **Given** an interactive edit session, **When** the configuration owner selects an existing rule to edit, **Then** they can change any of its top-level fields and its condition tree — adding, editing, removing, or renesting conditions and `all`/`any` groups within the configured depth limit — and the change is held in memory, unsaved, until the session validates and saves.
|
||||||
|
8. **Given** an interactive edit session, **When** the configuration owner deletes a rule, **Then** they are shown the rule's `id`, `name`, and `priority` and asked to confirm before it is removed from the in-memory document.
|
||||||
|
9. **Given** a rule add, edit, or delete has been made in an edit session, **When** the session re-validates or saves, **Then** the same four validation layers (VR-001) run over the modified document exactly as they would over a hand-edited file, and any resulting Error finding blocks the save.
|
||||||
|
10. **Given** an edit session with unsaved add, edit, or delete changes, **When** the operator quits without saving, **Then** they are warned that unsaved changes will be lost and the file on disk is unchanged.
|
||||||
|
11. **Given** a rule add, edit, or delete would exceed the configured maximum condition nesting depth, **When** the change is applied, **Then** it is rejected at edit time with the same finding the runtime validator would produce, rather than deferred to the next save.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -219,6 +226,10 @@ Requirement identifiers are carried forward unchanged from the Phase 0 baseline
|
|||||||
- **FR-024** — Non-interactive validation: the configuration tool MUST support non-interactive validation and return a non-zero exit code on failure.
|
- **FR-024** — Non-interactive validation: the configuration tool MUST support non-interactive validation and return a non-zero exit code on failure.
|
||||||
- **FR-025** — Configuration test data: the editor MUST support testing rules against synthetic sample users without tenant connectivity.
|
- **FR-025** — Configuration test data: the editor MUST support testing rules against synthetic sample users without tenant connectivity.
|
||||||
- **FR-026** — Backup before save: interactive edits MUST be validated before save and SHOULD create a timestamped backup or save-as output before replacing an existing configuration.
|
- **FR-026** — Backup before save: interactive edits MUST be validated before save and SHOULD create a timestamped backup or save-as output before replacing an existing configuration.
|
||||||
|
- **FR-027** — Add a rule: the interactive editor MUST support adding a new rule, prompting for every field in RE-001 and for the rule's condition tree (nested `all`/`any` groups and leaf conditions), and MUST reject an `id` or `priority` that collides with an existing rule before the rule is added.
|
||||||
|
- **FR-028** — Edit a rule: the interactive editor MUST support editing any field of an existing rule, including full editing of its condition tree — adding, changing, removing, and renesting conditions and groups within the configured depth limit (RE-004).
|
||||||
|
- **FR-029** — Delete a rule: the interactive editor MUST support deleting an existing rule, and MUST require the operator to confirm against the rule's `id`, `name`, and `priority` before removal.
|
||||||
|
- **FR-030** — Uniform validation for structural edits: a rule added, edited, or deleted in the interactive editor MUST be subject to the same four validation layers (VR-001) as a hand-edited file, at re-validation and at save; edits are held only in memory until save succeeds (FR-026).
|
||||||
|
|
||||||
### Rule Engine Requirements
|
### Rule Engine Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -28,16 +28,21 @@ pass offline Pester tests before any Graph integration exists.
|
|||||||
Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`,
|
Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`,
|
||||||
per the Project Structure section of [plan.md](plan.md).
|
per the Project Structure section of [plan.md](plan.md).
|
||||||
|
|
||||||
## Implementation status — 2026-08-20
|
## Implementation status — 2026-08-21
|
||||||
|
|
||||||
**109 of 121 tasks complete.** All twelve remaining tasks require something this workstation does not
|
**117 of 129 tasks complete.** All twelve remaining tasks require something this workstation does not
|
||||||
have: a tenant connection (T055, T056, T101–T103) or an Azure Automation account (Phase 13,
|
have: a tenant connection (T055, T056, T101–T103) or an Azure Automation account (Phase 13,
|
||||||
T115–T121). Nothing offline-implementable is outstanding.
|
T115–T121). Nothing offline-implementable is outstanding.
|
||||||
|
|
||||||
|
T122–T129 (interactive add/edit/delete for rules, FR-027–FR-030) landed after the initial Phase 6
|
||||||
|
build, which had shipped with toggle/priority editing only. 400 offline Pester tests now pass (up
|
||||||
|
from 354); see [traceability.md](traceability.md) for FR-027–FR-030's implementation and test
|
||||||
|
mapping.
|
||||||
|
|
||||||
```
|
```
|
||||||
354 offline Pester tests PASS
|
400 offline Pester tests PASS
|
||||||
Engine purity (Principle IV) PASS
|
Engine purity (Principle IV) PASS
|
||||||
Sanitization (SC-013) PASS 156 files
|
Sanitization (SC-013) PASS
|
||||||
Graph module loaded during tests False (SC-008 holds)
|
Graph module loaded during tests False (SC-008 holds)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -235,7 +240,28 @@ documented finding code, severity, and location.
|
|||||||
- [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026)
|
- [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026)
|
||||||
- [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025)
|
- [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025)
|
||||||
|
|
||||||
**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file.
|
### Rule CRUD in the interactive editor (US5 addition — FR-027–FR-030)
|
||||||
|
|
||||||
|
Added after the initial Phase 6 build. The editor originally shipped deliberately scoped to
|
||||||
|
toggle/priority only (see the `Invoke-InteractiveEditor` comment header); spec.md now requires
|
||||||
|
add/edit/delete, including full condition-tree editing, so that scope is reversed here rather than
|
||||||
|
worked around.
|
||||||
|
|
||||||
|
#### Tests for rule CRUD
|
||||||
|
|
||||||
|
- [X] T122 [P] [US5] Add-rule tests in tests/Configuration/EditorAddRule.Tests.ps1 covering the full field/condition-tree prompt sequence and rejection of a duplicate `id` or `priority` before the rule is added (FR-027)
|
||||||
|
- [X] T123 [P] [US5] Edit-rule tests in tests/Configuration/EditorEditRule.Tests.ps1 covering top-level field changes and condition-tree add/edit/remove/renest operations within `MaxConditionDepth` (FR-028)
|
||||||
|
- [X] T124 [P] [US5] Delete-rule tests in tests/Configuration/EditorDeleteRule.Tests.ps1 covering the `id`/`name`/`priority` confirmation prompt and removal, including a declined confirmation leaving the rule in place (FR-029)
|
||||||
|
- [X] T125 [P] [US5] Structural-edit validation tests in tests/Configuration/EditorStructuralEdits.Tests.ps1 asserting add/edit/delete changes stay in memory until save, re-validate through all four layers on `[V]`/`[S]`, and that a depth-limit violation is reported immediately at edit time rather than deferred (FR-030)
|
||||||
|
|
||||||
|
#### Implementation for rule CRUD
|
||||||
|
|
||||||
|
- [X] T126 [US5] Implement rule addition (prompt for every RE-001 field plus the condition tree, reject a colliding `id`/`priority`) in Edit-PersonaEngineConfig.ps1 and wire the `[A]` command into the interactive loop (FR-027)
|
||||||
|
- [X] T127 [US5] Implement rule editing (top-level fields plus add/edit/remove/renest on the condition tree) in Edit-PersonaEngineConfig.ps1 and wire the `[E]` command into the interactive loop (FR-028)
|
||||||
|
- [X] T128 [US5] Implement rule deletion with an `id`/`name`/`priority` confirmation prompt in Edit-PersonaEngineConfig.ps1 and wire the `[D]` command into the interactive loop (FR-029)
|
||||||
|
- [X] T129 [US5] Route add/edit/delete through the existing `$dirty` tracking and `Invoke-ConfigurationValidation` re-validation path in Edit-PersonaEngineConfig.ps1, and surface a depth-limit finding at edit time using the same check the runtime validator uses (FR-030)
|
||||||
|
|
||||||
|
**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file, and rules can be added, edited, and deleted entirely from the editor.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -430,7 +456,7 @@ reads the audit shape US7 defines.
|
|||||||
| 3 | T016–T020 (all tests, separate files) |
|
| 3 | T016–T020 (all tests, separate files) |
|
||||||
| 4 | T027, T028, T029 |
|
| 4 | T027, T028, T029 |
|
||||||
| 5 | T034–T038 (tests); then T039, T040 |
|
| 5 | T034–T038 (tests); then T039, T040 |
|
||||||
| 6 | T057–T060 |
|
| 6 | T057–T060; then T122–T125 |
|
||||||
| 7 | T071, T072 |
|
| 7 | T071, T072 |
|
||||||
| 8 | T077, T078 |
|
| 8 | T077, T078 |
|
||||||
| 9 | T083, T084, T085 |
|
| 9 | T083, T084, T085 |
|
||||||
@@ -470,7 +496,7 @@ Automation PowerShell 7 run.
|
|||||||
| 3 | US2 (P1) | T016–T026 | 11 |
|
| 3 | US2 (P1) | T016–T026 | 11 |
|
||||||
| 4 | US3 (P1) | T027–T033 | 7 |
|
| 4 | US3 (P1) | T027–T033 | 7 |
|
||||||
| 5 | US1 (P1) 🎯 | T034–T056 | 23 |
|
| 5 | US1 (P1) 🎯 | T034–T056 | 23 |
|
||||||
| 6 | US5 (P2) | T057–T070 | 14 |
|
| 6 | US5 (P2) | T057–T070, T122–T129 | 22 |
|
||||||
| 7 | US6 (P2) | T071–T076 | 6 |
|
| 7 | US6 (P2) | T071–T076 | 6 |
|
||||||
| 8 | US4 (P2) | T077–T082 | 6 |
|
| 8 | US4 (P2) | T077–T082 | 6 |
|
||||||
| 9 | US7 (P2) | T083–T090 | 8 |
|
| 9 | US7 (P2) | T083–T090 | 8 |
|
||||||
@@ -478,4 +504,4 @@ Automation PowerShell 7 run.
|
|||||||
| 11 | US8 (P3) 🔒 | T095–T103 | 9 |
|
| 11 | US8 (P3) 🔒 | T095–T103 | 9 |
|
||||||
| 12 Polish | — | T104–T114 | 11 |
|
| 12 Polish | — | T104–T114 | 11 |
|
||||||
| 13 Stage B | — | T115–T121 | 7 (deferred) |
|
| 13 Stage B | — | T115–T121 | 7 (deferred) |
|
||||||
| **Total** | | | **121** |
|
| **Total** | | | **129** |
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ Every functional requirement, non-functional requirement, and success criterion
|
|||||||
A row with no test is a requirement nobody is checking. Those are listed explicitly at the bottom
|
A row with no test is a requirement nobody is checking. Those are listed explicitly at the bottom
|
||||||
rather than left out, because an incomplete matrix that looks complete is worse than no matrix.
|
rather than left out, because an incomplete matrix that looks complete is worse than no matrix.
|
||||||
|
|
||||||
**Status as at 2026-08-20**: 354 offline tests passing; engine purity and sanitization gates passing;
|
**Status as at 2026-08-21**: 400 offline tests passing; engine purity and sanitization gates passing;
|
||||||
no tenant-dependent item verified.
|
no tenant-dependent item verified. The increase from 354 is FR-027–FR-030 (rule add/edit/delete in
|
||||||
|
the interactive editor), added to spec.md and implemented in the same change (T122–T129).
|
||||||
|
|
||||||
## Functional requirements
|
## Functional requirements
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ no tenant-dependent item verified.
|
|||||||
| FR-024 | Non-interactive validation with exit codes | `-NonInteractive` short-circuit | `NonInteractive.Tests.ps1` |
|
| FR-024 | Non-interactive validation with exit codes | `-NonInteractive` short-circuit | `NonInteractive.Tests.ps1` |
|
||||||
| FR-025 | Synthetic rule testing, no tenant | `Invoke-SyntheticRuleTest` | `NonInteractive.Tests.ps1` |
|
| FR-025 | Synthetic rule testing, no tenant | `Invoke-SyntheticRuleTest` | `NonInteractive.Tests.ps1` |
|
||||||
| FR-026 | Validate and back up before save | `Save-PersonaConfiguration` | `Safety.Tests.ps1` (`PE-SAF-007`) |
|
| FR-026 | Validate and back up before save | `Save-PersonaConfiguration` | `Safety.Tests.ps1` (`PE-SAF-007`) |
|
||||||
|
| FR-027 | Add a rule interactively | `Add-PersonaConfigRule`; `[A]` command in `Edit-PersonaEngineConfig.ps1` | `EditorAddRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
|
||||||
|
| FR-028 | Edit a rule interactively, including its condition tree | `Get/Add/Remove-PersonaConditionNode`, `Set-PersonaConditionLeaf`; `[E]` command | `EditorEditRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
|
||||||
|
| FR-029 | Delete a rule interactively, with confirmation | `Remove-PersonaConfigRule`; `[D]` command | `EditorDeleteRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
|
||||||
|
| FR-030 | Structural edits validated like a hand-edited file | `Test-PersonaCandidateEdit` in `Edit-PersonaEngineConfig.ps1` | `EditorStructuralEdits.Tests.ps1` |
|
||||||
|
|
||||||
## Rule engine requirements
|
## Rule engine requirements
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
function Add-PersonaConditionNode {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Appends a condition or nested group to a group in a rule's condition tree (FR-028).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Locates the group at ParentPath and appends Node to its `conditions`
|
||||||
|
collection. Structural checks only - id/priority-style collisions do not apply
|
||||||
|
to conditions, and nesting-depth enforcement is deliberately not duplicated
|
||||||
|
here.
|
||||||
|
|
||||||
|
Depth is enforced by re-running the real validator (VR-001) against the whole
|
||||||
|
candidate document after the edit, the same way a hand-edited file would be
|
||||||
|
checked - see Edit-PersonaEngineConfig.ps1's candidate-edit wrapper. A second,
|
||||||
|
local depth calculation here would risk drifting from Test-PersonaConditionGroup's
|
||||||
|
engine semantics (RE-004), which count nesting by group levels, not by leaf
|
||||||
|
conditions - the exact mismatch VR-002's own depth check is careful to avoid
|
||||||
|
(see Test-PersonaConfigurationSemantic.ps1).
|
||||||
|
|
||||||
|
.PARAMETER Group
|
||||||
|
The rule's `match` condition group (the root).
|
||||||
|
|
||||||
|
.PARAMETER ParentPath
|
||||||
|
Path (see Get-PersonaConditionNode) to the group to append into. Empty selects
|
||||||
|
the root.
|
||||||
|
|
||||||
|
.PARAMETER Node
|
||||||
|
The condition or condition group to append.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Object - the same Group, mutated in place, returned for convenience.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
[OutputType([object])]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[object] $Group,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[AllowEmptyCollection()]
|
||||||
|
[int[]] $ParentPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[object] $Node
|
||||||
|
)
|
||||||
|
|
||||||
|
$parent = Get-PersonaConditionNode -Group $Group -Path $ParentPath
|
||||||
|
|
||||||
|
if ($null -eq $parent.PSObject.Properties['conditions']) {
|
||||||
|
throw 'The target node is a leaf condition, not a group. Choose a group (all/any) to add into.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent.conditions = @($parent.conditions) + $Node
|
||||||
|
|
||||||
|
$Group
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
function Add-PersonaConfigRule {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Appends a new rule to a rules collection, rejecting an id/priority collision (FR-027).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Pure: takes the existing rules and a candidate rule, returns a new array. Never
|
||||||
|
mutates the input collection, so a caller that discards the result on a
|
||||||
|
validation failure has changed nothing.
|
||||||
|
|
||||||
|
The id/priority check is duplicated with PE-SEM-001/PE-SEM-002 by design - the
|
||||||
|
interactive editor should not need a full validation round trip just to tell an
|
||||||
|
operator they typed an id that already exists. Anything that reaches the
|
||||||
|
semantic layer anyway (for example a collision introduced by two concurrent
|
||||||
|
edits) is still caught there.
|
||||||
|
|
||||||
|
.PARAMETER Rules
|
||||||
|
The rule collection to append to. Not mutated.
|
||||||
|
|
||||||
|
.PARAMETER Rule
|
||||||
|
The candidate rule. Must carry non-blank `id` and a `priority`.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Object[] - the new rules collection, existing rules first.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
[OutputType([object[]])]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[AllowEmptyCollection()]
|
||||||
|
[object[]] $Rules,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[object] $Rule
|
||||||
|
)
|
||||||
|
|
||||||
|
$id = [string]$Rule.id
|
||||||
|
if ([string]::IsNullOrWhiteSpace($id)) {
|
||||||
|
throw 'The new rule requires a non-blank id.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = @($Rules)
|
||||||
|
|
||||||
|
$byId = @($existing | Where-Object { [string]$_.id -eq $id })
|
||||||
|
if ($byId.Count -gt 0) {
|
||||||
|
throw "Rule id '$id' already exists (priority $($byId[0].priority)). Rule ids must be unique (RE-002)."
|
||||||
|
}
|
||||||
|
|
||||||
|
$priority = $Rule.priority
|
||||||
|
$byPriority = @($existing | Where-Object { [int]$_.priority -eq [int]$priority })
|
||||||
|
if ($byPriority.Count -gt 0) {
|
||||||
|
throw "Priority $priority is already used by rule '$($byPriority[0].id)'. Priorities must be unique (RE-002)."
|
||||||
|
}
|
||||||
|
|
||||||
|
, ($existing + $Rule)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
function Get-PersonaConditionNode {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Navigates a rule's condition tree to the node at a path (FR-028).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
A path is a sequence of zero-based indices into successive `.conditions`
|
||||||
|
collections, root first. An empty path returns the root group itself.
|
||||||
|
|
||||||
|
The returned node is the same object reference that lives inside the tree -
|
||||||
|
deliberately. `ConvertFrom-Json` produces `PSCustomObject`/array trees of
|
||||||
|
reference types, so a caller that sets a property on the returned leaf (for
|
||||||
|
example `$node.value = 'x'`) mutates the tree in place. Editing a leaf's
|
||||||
|
fields is therefore a plain property assignment, not a separate setter
|
||||||
|
function; see Set-PersonaConditionLeaf for the one case (switching a
|
||||||
|
condition's operator/type) where stale fields must also be cleared.
|
||||||
|
|
||||||
|
.PARAMETER Group
|
||||||
|
The rule's `match` condition group (the root).
|
||||||
|
|
||||||
|
.PARAMETER Path
|
||||||
|
Zero-based indices from the root, one per nesting level. Empty selects the root.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Object - the condition or condition group at that path.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
[OutputType([object])]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[object] $Group,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[AllowEmptyCollection()]
|
||||||
|
[int[]] $Path
|
||||||
|
)
|
||||||
|
|
||||||
|
$node = $Group
|
||||||
|
|
||||||
|
foreach ($index in $Path) {
|
||||||
|
if ($null -eq $node.PSObject.Properties['conditions'] -or $null -eq $node.conditions) {
|
||||||
|
throw "Path segment $index does not resolve: the node above it is a leaf condition, not a group."
|
||||||
|
}
|
||||||
|
|
||||||
|
$children = @($node.conditions)
|
||||||
|
if ($index -lt 0 -or $index -ge $children.Count) {
|
||||||
|
throw "Path segment $index is out of range - this group has $($children.Count) condition(s)."
|
||||||
|
}
|
||||||
|
|
||||||
|
$node = $children[$index]
|
||||||
|
}
|
||||||
|
|
||||||
|
$node
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
function Remove-PersonaConditionNode {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Removes a condition or nested group from a rule's condition tree (FR-028).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Two structural rules are enforced here rather than left to schema validation,
|
||||||
|
because both would otherwise produce a document the schema calls invalid with
|
||||||
|
no indication which editor action caused it:
|
||||||
|
|
||||||
|
- The root group itself cannot be removed - a rule's `match` is required
|
||||||
|
(RE-001). Delete the rule instead (Remove-PersonaConfigRule).
|
||||||
|
- A group's last remaining condition cannot be removed - an empty
|
||||||
|
`conditions` array violates the schema's `minItems: 1` on conditionGroup.
|
||||||
|
Remove the parent group instead, or add a replacement first.
|
||||||
|
|
||||||
|
.PARAMETER Group
|
||||||
|
The rule's `match` condition group (the root).
|
||||||
|
|
||||||
|
.PARAMETER Path
|
||||||
|
Path (see Get-PersonaConditionNode) to the node to remove. Must not be empty.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Object - the same Group, mutated in place, returned for convenience.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
[OutputType([object])]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[object] $Group,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[AllowEmptyCollection()]
|
||||||
|
[int[]] $Path
|
||||||
|
)
|
||||||
|
|
||||||
|
if (@($Path).Count -eq 0) {
|
||||||
|
throw "The root condition group cannot be removed this way - delete the rule instead."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Special-cased at length 1 because PowerShell's range operator treats 0..-1 as
|
||||||
|
# the two-element descending range (0, -1), not an empty range - the naive slice
|
||||||
|
# would silently pick up the wrong parent for a top-level path.
|
||||||
|
$parentPath = (@($Path).Count -eq 1) ? @() : @($Path)[0..($Path.Count - 2)]
|
||||||
|
$index = @($Path)[-1]
|
||||||
|
|
||||||
|
$parent = Get-PersonaConditionNode -Group $Group -Path $parentPath
|
||||||
|
$children = @($parent.conditions)
|
||||||
|
|
||||||
|
if ($index -lt 0 -or $index -ge $children.Count) {
|
||||||
|
throw "Path segment $index is out of range - this group has $($children.Count) condition(s)."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($children.Count -eq 1) {
|
||||||
|
throw 'This is the only condition in its group. A group requires at least one condition - remove the group itself instead, or add a replacement first.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Removed by position, not by value or reference - two structurally identical
|
||||||
|
# sibling conditions are otherwise indistinguishable.
|
||||||
|
$remaining = [System.Collections.Generic.List[object]]::new()
|
||||||
|
for ($i = 0; $i -lt $children.Count; $i++) {
|
||||||
|
if ($i -ne $index) { $remaining.Add($children[$i]) }
|
||||||
|
}
|
||||||
|
$parent.conditions = $remaining.ToArray()
|
||||||
|
|
||||||
|
$Group
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
function Remove-PersonaConfigRule {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Removes a rule from a rules collection by id (FR-029).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Pure: takes the existing rules and an id, returns a new array with that rule
|
||||||
|
absent. Throws if no rule carries the id, because a silent no-op would let an
|
||||||
|
operator believe a delete happened when it did not.
|
||||||
|
|
||||||
|
.PARAMETER Rules
|
||||||
|
The rule collection to remove from. Not mutated.
|
||||||
|
|
||||||
|
.PARAMETER RuleId
|
||||||
|
The id of the rule to remove.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Object[] - the remaining rules.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
[OutputType([object[]])]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[AllowEmptyCollection()]
|
||||||
|
[object[]] $Rules,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string] $RuleId
|
||||||
|
)
|
||||||
|
|
||||||
|
$existing = @($Rules)
|
||||||
|
$match = @($existing | Where-Object { [string]$_.id -eq $RuleId })
|
||||||
|
|
||||||
|
if ($match.Count -eq 0) {
|
||||||
|
throw "No rule with id '$RuleId'."
|
||||||
|
}
|
||||||
|
|
||||||
|
, @($existing | Where-Object { [string]$_.id -ne $RuleId })
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
function Set-PersonaConditionLeaf {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Replaces a leaf condition's fields in place (FR-028).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Every condition-specific field (`property`, `value`, `values`,
|
||||||
|
`groupObjectIds`, `roleIds`, `membershipMode`) is cleared before Fields is
|
||||||
|
applied, then only the keys in Fields are set. This is not tidiness - the
|
||||||
|
schema's `additionalProperties: false` on `condition` (VR-001 layer 2) rejects
|
||||||
|
a stray field outright, and switching, say, a condition from `equals` (which
|
||||||
|
carries `value`) to `in` (which carries `values`) would otherwise leave the
|
||||||
|
old `value` behind and fail schema validation for a reason the editor caused
|
||||||
|
but did not explain.
|
||||||
|
|
||||||
|
`type` and `operator` are cleared and reset the same way, since a caller
|
||||||
|
editing a condition typically passes both.
|
||||||
|
|
||||||
|
.PARAMETER Node
|
||||||
|
The leaf condition to edit (from Get-PersonaConditionNode). Mutated in place.
|
||||||
|
|
||||||
|
.PARAMETER Fields
|
||||||
|
The complete replacement field set for this leaf, for example:
|
||||||
|
`@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }`.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Object - the same Node, mutated in place, returned for convenience.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
[OutputType([object])]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[object] $Node,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[hashtable] $Fields
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($null -ne $Node.PSObject.Properties['conditions']) {
|
||||||
|
throw 'The target node is a condition group, not a leaf condition.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$knownFields = @('type', 'property', 'operator', 'value', 'values', 'groupObjectIds', 'roleIds', 'membershipMode', 'caseSensitive')
|
||||||
|
|
||||||
|
foreach ($key in $knownFields) {
|
||||||
|
if ($null -ne $Node.PSObject.Properties[$key]) {
|
||||||
|
$Node.PSObject.Properties.Remove($key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entry in $Fields.GetEnumerator()) {
|
||||||
|
$Node | Add-Member -NotePropertyName $entry.Key -NotePropertyValue $entry.Value -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$Node
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
#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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
#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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user