From aeedb7170a9a2a9838880f68bcde5865de013772 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 21 Aug 2026 00:31:47 -0400 Subject: [PATCH] Removed extension attribute requirement for testing --- README.md | 2 +- .../Invoke-PersonaGraphRequest.ps1 | 36 +++++++++++ src/Engine/Invoke-PersonaEngineRun.ps1 | 29 ++++++++- tests/Unit/ExitCodes.Tests.ps1 | 62 +++++++++++++++++++ 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dc8e103..6769042 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ cd persona-engine ### Step 2 — Prove the machine can run it, before touching a tenant ```bash -pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }" +pwsh -NoProfile -Command '& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }' ``` Expect **354 passed, 0 failed**. This needs no credentials and no network. If it does not pass, stop diff --git a/src/DataProviders/Invoke-PersonaGraphRequest.ps1 b/src/DataProviders/Invoke-PersonaGraphRequest.ps1 index 5138d32..9fc8157 100644 --- a/src/DataProviders/Invoke-PersonaGraphRequest.ps1 +++ b/src/DataProviders/Invoke-PersonaGraphRequest.ps1 @@ -149,6 +149,42 @@ function Get-PersonaGraphStatusCode { return $null } +function Test-PersonaTargetAttributeUnavailable { + <# + .SYNOPSIS + True when a Graph 400 means the target attribute does not exist in this + tenant, rather than some other client error. + + .DESCRIPTION + A dev tenant with no app registration has no persona extension property. + Requesting it in $select then fails with 400 and a message naming the + property. This distinguishes that specific, recoverable case from every + other 400 (bad query syntax, an unrelated bad property, a permission denial + phrased as 400), which must still fail loudly rather than being swallowed. + + .PARAMETER ErrorRecord + The error caught from Invoke-PersonaGraphRequest. + + .PARAMETER TargetAttribute + The configured target attribute name. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] $ErrorRecord, + [Parameter(Mandatory)] [string] $TargetAttribute + ) + + if ((Get-PersonaGraphStatusCode -ErrorRecord $ErrorRecord) -ne 400) { return $false } + + $text = @($ErrorRecord.Exception.Message, $ErrorRecord.ErrorDetails.Message) -join ' ' + if ([string]::IsNullOrWhiteSpace($text)) { return $false } + + if ($text -notmatch [regex]::Escape($TargetAttribute)) { return $false } + + [bool]($text -match '(?i)could not find a property|invalid property|is not a valid property|does not exist on type') +} + function Get-PersonaRetryAfterMs { <# .SYNOPSIS diff --git a/src/Engine/Invoke-PersonaEngineRun.ps1 b/src/Engine/Invoke-PersonaEngineRun.ps1 index cdf7bfd..d6ce285 100644 --- a/src/Engine/Invoke-PersonaEngineRun.ps1 +++ b/src/Engine/Invoke-PersonaEngineRun.ps1 @@ -98,9 +98,32 @@ function Invoke-PersonaEngineRun { : @(Get-PersonaUsers -SelectProperties $selectProperties) } catch { - # A partial population is worse than none: half a tenant classified looks - # like a successful run to everything downstream. - return New-PersonaRunOutcome -Counters $counters -ExitCode $EXIT_ENUMERATION -FailureReason $_.Exception.Message + if ((-not $IsEnforcing) -and (Test-PersonaTargetAttributeUnavailable -ErrorRecord $_ -TargetAttribute $TargetAttribute)) { + # Dev/test tenants often have no app registration yet, so the persona + # extension property was never created. Preview mode never writes + # regardless of what StoredPersona holds, so treating the attribute as + # absent (null) here is safe and lets development proceed without one. + # Enforcement still fails loudly - IsEnforcing gates this precisely + # because writing to an attribute that does not exist must never be + # silently tolerated. + Write-Warning "Target attribute '$TargetAttribute' was not found in this tenant (no app registration / extension property?). Continuing in What-If mode with it treated as null for every account." + + $fallbackProperties = @($selectProperties | Where-Object { $_ -cne $TargetAttribute }) + + try { + $users = $UserObjectId ` + ? @(Get-PersonaUsers -SelectProperties $fallbackProperties -UserObjectId $UserObjectId) ` + : @(Get-PersonaUsers -SelectProperties $fallbackProperties) + } + catch { + return New-PersonaRunOutcome -Counters $counters -ExitCode $EXIT_ENUMERATION -FailureReason $_.Exception.Message + } + } + else { + # A partial population is worse than none: half a tenant classified looks + # like a successful run to everything downstream. + return New-PersonaRunOutcome -Counters $counters -ExitCode $EXIT_ENUMERATION -FailureReason $_.Exception.Message + } } foreach ($graphUser in $users) { diff --git a/tests/Unit/ExitCodes.Tests.ps1 b/tests/Unit/ExitCodes.Tests.ps1 index fbbccd8..c6bf333 100644 --- a/tests/Unit/ExitCodes.Tests.ps1 +++ b/tests/Unit/ExitCodes.Tests.ps1 @@ -142,6 +142,68 @@ Describe 'Exit codes produced by the run loop' { } } +Describe 'Target attribute unavailable - dev tenants without an app registration' { + + BeforeAll { + $script:unavailableError = "Response status code does not indicate success: 400 (Bad Request): Could not find a property named '$($script:target)' on type 'microsoft.graph.user'." + } + + BeforeEach { + Mock Write-Host { } + Mock Write-Verbose { } + Mock Write-Warning { } + } + + It 'continues in What-If mode, treating the attribute as null, when it is not registered' { + Mock Get-PersonaUsers { + param($SelectProperties, $UserObjectId, $PageSize) + if ($SelectProperties -ccontains $script:target) { throw $script:unavailableError } + @(New-TestPopulation -Count 5 -TargetAttribute $script:target) + } + + $outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext) -IsEnforcing:$false + + $outcome.ExitCode | Should -Be 0 + $outcome.Counters.Processed | Should -Be 5 + Should -Invoke Get-PersonaUsers -Times 2 -Exactly + Should -Invoke Write-Warning -Times 1 -Exactly + } + + It 'requests everything except the target attribute on the retry' { + Mock Get-PersonaUsers { + param($SelectProperties, $UserObjectId, $PageSize) + if ($SelectProperties -ccontains $script:target) { throw $script:unavailableError } + @(New-TestPopulation -Count 5 -TargetAttribute $script:target) + } + + $null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext) -IsEnforcing:$false + + Should -Invoke Get-PersonaUsers -Times 1 -Exactly -ParameterFilter { $SelectProperties -cnotcontains $script:target } + } + + It 'still fails enumeration in enforcement mode - the fallback never applies to a real write run' { + Mock Get-PersonaUsers { throw $script:unavailableError } + + $outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing + + $outcome.ExitCode | Should -Be 3 + Should -Invoke Get-PersonaUsers -Times 1 -Exactly + } + + It 'does not swallow an unrelated enumeration failure even in What-If mode' { + Mock Get-PersonaUsers { throw 'Graph request failed after 5 attempt(s) (last status: 503): service unavailable' } + + $outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext) -IsEnforcing:$false + + $outcome.ExitCode | Should -Be 3 + Should -Invoke Get-PersonaUsers -Times 1 -Exactly + } +} + Describe 'Exit codes owned by the entry script' { BeforeAll {