381 lines
14 KiB
PowerShell
381 lines
14 KiB
PowerShell
|
|
<#
|
||
|
|
Shared test helpers.
|
||
|
|
|
||
|
|
Loads the module's source files by dot-sourcing each layer rather than importing
|
||
|
|
PersonaEngine.psd1. Two reasons, both load-bearing:
|
||
|
|
|
||
|
|
1. The manifest declares Microsoft.Graph.Authentication as a required module.
|
||
|
|
Importing it would pull that module into the session, and SC-008 requires
|
||
|
|
the offline suites to run with no Graph module loaded at all. Dot-sourcing
|
||
|
|
is the practical proof that the pure layers do not need it.
|
||
|
|
|
||
|
|
2. Pester's Mock replaces functions in the scope where they are defined.
|
||
|
|
Dot-sourced functions land in the test file's scope, so mocking
|
||
|
|
Get-PersonaUsers or Set-UserPersonaAttribute works without -ModuleName
|
||
|
|
plumbing on every call.
|
||
|
|
|
||
|
|
Nothing here fabricates engine behaviour. The functions under test are the
|
||
|
|
shipped ones; only the Graph boundary is replaced.
|
||
|
|
#>
|
||
|
|
|
||
|
|
if (-not (Get-Command 'Invoke-MgGraphRequest' -ErrorAction SilentlyContinue)) {
|
||
|
|
function Invoke-MgGraphRequest {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
Stand-in for the real cmdlet when Microsoft.Graph.Authentication is absent.
|
||
|
|
|
||
|
|
.DESCRIPTION
|
||
|
|
SC-008 requires the offline suites to run with no Graph module loaded, and
|
||
|
|
Pester cannot mock a command that does not exist. This stub gives Mock
|
||
|
|
something to replace.
|
||
|
|
|
||
|
|
It throws if it is ever actually called. A stub that returned plausible
|
||
|
|
data would let a test pass while silently exercising nothing, which is
|
||
|
|
worse than no test at all.
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
param(
|
||
|
|
[string] $Uri,
|
||
|
|
[string] $Method,
|
||
|
|
[object] $Body,
|
||
|
|
[string] $ContentType,
|
||
|
|
[hashtable] $Headers,
|
||
|
|
[string] $OutputType
|
||
|
|
)
|
||
|
|
|
||
|
|
throw 'Invoke-MgGraphRequest stub was called without being mocked. A test reached the real Graph boundary.'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function Get-PersonaSourceFile {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
Returns the module's source files in load order, for the caller to dot-source.
|
||
|
|
|
||
|
|
.DESCRIPTION
|
||
|
|
Returns paths rather than dot-sourcing them itself. Dot-sourcing inside a
|
||
|
|
function loads into that function's scope, which disappears when it returns -
|
||
|
|
the functions would be defined and immediately unreachable. The caller has to
|
||
|
|
do it:
|
||
|
|
|
||
|
|
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
|
||
|
|
|
||
|
|
The Authentication layer is excluded by default: it is the one layer that
|
||
|
|
calls Connect-MgGraph, and loading it is unnecessary for any offline suite.
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
[OutputType([string[]])]
|
||
|
|
param(
|
||
|
|
[Parameter(Mandatory)]
|
||
|
|
[string] $RepoRoot,
|
||
|
|
|
||
|
|
[string[]] $Layers = @(
|
||
|
|
'Normalization', 'Configuration', 'RuleEngine',
|
||
|
|
'DataProviders', 'Persistence', 'Presentation', 'Engine', 'Audit'
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
$files = [System.Collections.Generic.List[string]]::new()
|
||
|
|
|
||
|
|
foreach ($layer in $Layers) {
|
||
|
|
$path = Join-Path $RepoRoot "src/$layer"
|
||
|
|
if (-not (Test-Path $path)) { continue }
|
||
|
|
|
||
|
|
foreach ($file in (Get-ChildItem -Path $path -Filter '*.ps1' -File | Sort-Object Name)) {
|
||
|
|
$files.Add($file.FullName)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
, $files.ToArray()
|
||
|
|
}
|
||
|
|
|
||
|
|
function New-TestConfigurationDocument {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
Builds a minimal valid configuration document as a hashtable.
|
||
|
|
|
||
|
|
.DESCRIPTION
|
||
|
|
Tests that need an INVALID configuration start from this and break exactly
|
||
|
|
one thing, so the finding under test is unambiguously caused by that one
|
||
|
|
change rather than by an unrelated defect in a hand-written fixture.
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
[OutputType([hashtable])]
|
||
|
|
param()
|
||
|
|
|
||
|
|
@{
|
||
|
|
configVersion = '1.0.0'
|
||
|
|
engine = @{
|
||
|
|
targetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
|
||
|
|
approvedWritableAttributes = @('extension_<EXTENSION-APP-ID>_<PERSONA>')
|
||
|
|
maxConditionDepth = 5
|
||
|
|
summaryInterval = 25
|
||
|
|
defaultMembershipMode = 'direct'
|
||
|
|
}
|
||
|
|
dataSources = @{
|
||
|
|
groups = @{ enabled = $true }
|
||
|
|
roles = @{ enabled = $true }
|
||
|
|
}
|
||
|
|
logging = @{ destination = 'stream' }
|
||
|
|
personas = @('Employee', 'Guest', 'Tier0-Admin')
|
||
|
|
rules = @(
|
||
|
|
@{
|
||
|
|
id = 'RULE-0010-GUEST'
|
||
|
|
name = 'Guest accounts'
|
||
|
|
description = 'Accounts whose user type is Guest.'
|
||
|
|
enabled = $true
|
||
|
|
priority = 10
|
||
|
|
persona = 'Guest'
|
||
|
|
match = @{
|
||
|
|
operator = 'all'
|
||
|
|
conditions = @(
|
||
|
|
@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
@{
|
||
|
|
id = 'RULE-0900-EMPLOYEE'
|
||
|
|
name = 'Employees'
|
||
|
|
description = 'Default classification for member accounts with a department.'
|
||
|
|
enabled = $true
|
||
|
|
priority = 900
|
||
|
|
persona = 'Employee'
|
||
|
|
match = @{
|
||
|
|
operator = 'all'
|
||
|
|
conditions = @(
|
||
|
|
@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function Save-TestConfiguration {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
Writes a configuration document to a temporary file and returns its path.
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
[OutputType([string])]
|
||
|
|
param(
|
||
|
|
[Parameter(Mandatory)] [hashtable] $Document,
|
||
|
|
[string] $Directory = ([System.IO.Path]::GetTempPath())
|
||
|
|
)
|
||
|
|
|
||
|
|
$path = Join-Path $Directory ("pe-test-{0}.json" -f [guid]::NewGuid().ToString('N'))
|
||
|
|
Set-Content -LiteralPath $path -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
||
|
|
$path
|
||
|
|
}
|
||
|
|
|
||
|
|
function New-TestGraphUser {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
Builds a raw Graph-shaped user hashtable.
|
||
|
|
|
||
|
|
.DESCRIPTION
|
||
|
|
A hashtable, because that is what Invoke-MgGraphRequest returns. Building
|
||
|
|
fixtures in the shape the real boundary produces is what makes the
|
||
|
|
normalization tests meaningful.
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
[OutputType([hashtable])]
|
||
|
|
param(
|
||
|
|
[Parameter(Mandatory)] [string] $Id,
|
||
|
|
[Parameter(Mandatory)] [string] $UserPrincipalName,
|
||
|
|
[string] $DisplayName = 'Test Account',
|
||
|
|
[string] $UserType = 'Member',
|
||
|
|
[bool] $AccountEnabled = $true,
|
||
|
|
[string] $Department = 'Finance',
|
||
|
|
[string] $CompanyName = '<ORGANIZATION-NAME>',
|
||
|
|
[string] $TargetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>',
|
||
|
|
[AllowEmptyString()] [string] $StoredPersona = ''
|
||
|
|
)
|
||
|
|
|
||
|
|
@{
|
||
|
|
id = $Id
|
||
|
|
userPrincipalName = $UserPrincipalName
|
||
|
|
displayName = $DisplayName
|
||
|
|
userType = $UserType
|
||
|
|
accountEnabled = $AccountEnabled
|
||
|
|
department = $Department
|
||
|
|
companyName = $CompanyName
|
||
|
|
$TargetAttribute = $StoredPersona
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function New-TestPopulation {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
Builds a synthetic population spanning every outcome and action.
|
||
|
|
|
||
|
|
.DESCRIPTION
|
||
|
|
Deliberately includes accounts that produce Matched, Unclassified, and - via
|
||
|
|
the membership fixture wired by the caller - EvaluationError, plus accounts
|
||
|
|
whose stored value already matches and accounts whose value would change. A
|
||
|
|
zero-write assertion over a population where nothing would change proves
|
||
|
|
nothing.
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
[OutputType([object[]])]
|
||
|
|
param(
|
||
|
|
[int] $Count = 30,
|
||
|
|
[string] $TargetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
|
||
|
|
)
|
||
|
|
|
||
|
|
$users = [System.Collections.Generic.List[object]]::new()
|
||
|
|
|
||
|
|
for ($i = 1; $i -le $Count; $i++) {
|
||
|
|
$id = '00000000-0000-0000-0000-{0:d12}' -f $i
|
||
|
|
|
||
|
|
# Every third account is a Guest whose stored value is stale, so a change is
|
||
|
|
# proposed. Every fifth has no department and matches nothing.
|
||
|
|
$isGuest = ($i % 3) -eq 0
|
||
|
|
$isUnclassifiable = -not $isGuest -and ($i % 5) -eq 0
|
||
|
|
|
||
|
|
$users.Add((New-TestGraphUser `
|
||
|
|
-Id $id `
|
||
|
|
-UserPrincipalName ("user{0:d4}@example.invalid" -f $i) `
|
||
|
|
-UserType ($isGuest ? 'Guest' : 'Member') `
|
||
|
|
-Department ($isUnclassifiable ? $null : 'Finance') `
|
||
|
|
-TargetAttribute $TargetAttribute `
|
||
|
|
-StoredPersona ($isGuest ? 'Employee' : ($isUnclassifiable ? '' : 'Employee'))))
|
||
|
|
}
|
||
|
|
|
||
|
|
# Emitted unwrapped, so a Mock body returning this call unrolls into the pipeline
|
||
|
|
# the way a real Get-PersonaUsers does. The `, $array` idiom would emit one
|
||
|
|
# object containing the array, and @() around the call would then produce a
|
||
|
|
# single-element population - a mistake that makes a 30-user test silently a
|
||
|
|
# 1-user test.
|
||
|
|
$users.ToArray()
|
||
|
|
}
|
||
|
|
|
||
|
|
function New-TestRuntimeConfiguration {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
Builds the configuration object shape Import-PersonaConfiguration produces.
|
||
|
|
|
||
|
|
.DESCRIPTION
|
||
|
|
The run loop consumes the imported object, not the raw JSON document. Tests
|
||
|
|
that exercise the loop build this directly so they are not also testing the
|
||
|
|
importer - a failure here should mean the loop is wrong, not that the parser
|
||
|
|
changed.
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
[OutputType([pscustomobject])]
|
||
|
|
param(
|
||
|
|
[string] $TargetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>',
|
||
|
|
[int] $SummaryInterval = 0,
|
||
|
|
[AllowNull()] [object] $EvaluationErrorThreshold = $null,
|
||
|
|
[object[]] $Rules,
|
||
|
|
[string] $DefaultMembershipMode = 'Direct'
|
||
|
|
)
|
||
|
|
|
||
|
|
if (-not $Rules) {
|
||
|
|
$Rules = @(
|
||
|
|
[pscustomobject]@{
|
||
|
|
id = 'RULE-0010-GUEST'; name = 'Guests'; enabled = $true; priority = 10; persona = 'Guest'
|
||
|
|
match = [pscustomobject]@{
|
||
|
|
operator = 'all'
|
||
|
|
conditions = @([pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
[pscustomobject]@{
|
||
|
|
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
|
||
|
|
match = [pscustomobject]@{
|
||
|
|
operator = 'all'
|
||
|
|
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
[pscustomobject]@{
|
||
|
|
ConfigVersion = '1.0.0'
|
||
|
|
ConfigurationHash = ('0' * 64)
|
||
|
|
SourcePath = '<CONFIG-PATH>'
|
||
|
|
TargetAttribute = $TargetAttribute
|
||
|
|
ApprovedWritableAttributes = @($TargetAttribute)
|
||
|
|
MaxConditionDepth = 5
|
||
|
|
SummaryInterval = $SummaryInterval
|
||
|
|
DefaultMembershipMode = $DefaultMembershipMode
|
||
|
|
EvaluationErrorThreshold = $EvaluationErrorThreshold
|
||
|
|
Rules = $Rules
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function New-TestMembershipRule {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
A rule requiring group membership, so a failed lookup becomes EvaluationError.
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
param(
|
||
|
|
[string] $GroupObjectId = '00000000-0000-0000-0000-0000000000a0',
|
||
|
|
[int] $Priority = 30,
|
||
|
|
[string] $Persona = 'Tier0-Admin'
|
||
|
|
)
|
||
|
|
|
||
|
|
[pscustomobject]@{
|
||
|
|
id = 'RULE-0030-TIER0'; name = 'Tier 0 administrators'; enabled = $true
|
||
|
|
priority = $Priority; persona = $Persona
|
||
|
|
match = [pscustomobject]@{
|
||
|
|
operator = 'all'
|
||
|
|
conditions = @([pscustomobject]@{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($GroupObjectId) })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function Get-CapturedAuditRecord {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
Extracts audit records from a captured Information stream.
|
||
|
|
|
||
|
|
.DESCRIPTION
|
||
|
|
Write-PersonaAuditRecord emits records on the Information stream so the
|
||
|
|
success stream stays free for the run outcome. Captured entries arrive as
|
||
|
|
InformationRecord wrappers; this unwraps them and optionally filters by type.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
|
||
|
|
$info = $null
|
||
|
|
$outcome = Invoke-PersonaEngineRun ... -InformationVariable info
|
||
|
|
$events = Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent'
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
param(
|
||
|
|
[AllowNull()] [object] $Captured,
|
||
|
|
[string] $RecordType
|
||
|
|
)
|
||
|
|
|
||
|
|
$records = foreach ($entry in @($Captured)) {
|
||
|
|
$record = ($entry -is [System.Management.Automation.InformationRecord]) ? $entry.MessageData : $entry
|
||
|
|
if ($record -is [System.Collections.IDictionary]) { $record }
|
||
|
|
}
|
||
|
|
|
||
|
|
# Both returns use the comma idiom. Without it a single matching record is
|
||
|
|
# unrolled onto the pipeline and the caller assigns the dictionary itself, so
|
||
|
|
# .Count reports the key count and [0] indexes a field rather than a record -
|
||
|
|
# which fails as a confusing type mismatch rather than as a missing record.
|
||
|
|
if ($RecordType) { return , @($records | Where-Object { $_['recordType'] -eq $RecordType }) }
|
||
|
|
|
||
|
|
, @($records)
|
||
|
|
}
|
||
|
|
|
||
|
|
function New-TestAuditContext {
|
||
|
|
<#
|
||
|
|
.SYNOPSIS
|
||
|
|
Builds an audit context without needing a real configuration file.
|
||
|
|
#>
|
||
|
|
[CmdletBinding()]
|
||
|
|
param(
|
||
|
|
[string] $Mode = 'Preview',
|
||
|
|
[string] $RunId = '00000000-0000-0000-0000-00000000f001'
|
||
|
|
)
|
||
|
|
|
||
|
|
New-PersonaAuditContext -RunId $RunId -EngineVersion '0.1.0' -Mode $Mode -Configuration ([pscustomobject]@{
|
||
|
|
ConfigVersion = '1.0.0'
|
||
|
|
ConfigurationHash = ('0' * 64)
|
||
|
|
})
|
||
|
|
}
|