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