#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# FR-004: @odata.nextLink is followed to exhaustion, and a truncated enumeration raises rather than returning a partial population. The second half is the one worth the effort. A partial population reconciles cleanly, produces a plausible summary, and reports success - so nothing downstream can tell that half the tenant was never looked at. #> BeforeAll { $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent . (Join-Path $repoRoot 'tests/TestHelpers.ps1') foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } $script:target = 'extension__' } Describe 'User enumeration pagination (FR-004)' { It 'follows nextLink across every page' { $script:page = 0 Mock Invoke-PersonaGraphRequest { $script:page++ $users = 1..3 | ForEach-Object { New-TestGraphUser -Id ('00000000-0000-0000-0000-{0:d12}' -f (($script:page - 1) * 3 + $_)) ` -UserPrincipalName ("user{0}-{1}@example.invalid" -f $script:page, $_) } $response = @{ value = $users } if ($script:page -lt 4) { $response['@odata.nextLink'] = "/v1.0/users?`$skiptoken=page$script:page" } $response } $result = @(Get-PersonaUsers -SelectProperties @('id', 'userPrincipalName')) $result.Count | Should -Be 12 Should -Invoke Invoke-PersonaGraphRequest -Times 4 -Exactly } It 'stops when nextLink is absent rather than looping' { Mock Invoke-PersonaGraphRequest { @{ value = @(New-TestGraphUser -Id '00000000-0000-0000-0000-000000000001' -UserPrincipalName 'a@example.invalid') } } $result = @(Get-PersonaUsers -SelectProperties @('id')) $result.Count | Should -Be 1 Should -Invoke Invoke-PersonaGraphRequest -Times 1 -Exactly } It 'raises on a response with no value collection rather than returning what it has' { $script:page = 0 Mock Invoke-PersonaGraphRequest { $script:page++ if ($script:page -eq 1) { return @{ value = @(New-TestGraphUser -Id '00000000-0000-0000-0000-000000000001' -UserPrincipalName 'a@example.invalid') '@odata.nextLink' = '/v1.0/users?$skiptoken=abc' } } # Page two comes back malformed. Returning page one alone would look like # a complete, tiny tenant. @{ error = 'unexpected' } } { @(Get-PersonaUsers -SelectProperties @('id')) } | Should -Throw -ExpectedMessage '*partial population*' } It 'raises on a null response' { Mock Invoke-PersonaGraphRequest { $null } { @(Get-PersonaUsers -SelectProperties @('id')) } | Should -Throw } It 'requests the maximum page size so a large tenant needs fewer round trips' { Mock Invoke-PersonaGraphRequest -ParameterFilter { $Uri -match '\$top=999' } -MockWith { @{ value = @() } } $null = @(Get-PersonaUsers -SelectProperties @('id')) Should -Invoke Invoke-PersonaGraphRequest -Times 1 -Exactly } } Describe 'Membership pagination' { It 'follows nextLink and collects only group objects' { $script:page = 0 Mock Invoke-PersonaGraphRequest { $script:page++ if ($script:page -eq 1) { return @{ value = @( @{ '@odata.type' = '#microsoft.graph.group'; id = '00000000-0000-0000-0000-0000000000a0' } # An administrative unit arriving on memberOf. Treating it as # a group ID would never match, which reads as "not a member". @{ '@odata.type' = '#microsoft.graph.administrativeUnit'; id = '00000000-0000-0000-0000-0000000000e0' } ) '@odata.nextLink' = '/v1.0/users/x/memberOf?$skiptoken=abc' } } @{ value = @(@{ '@odata.type' = '#microsoft.graph.group'; id = '00000000-0000-0000-0000-0000000000b0' }) } } $record = Get-PersonaGroupMembership -UserObjectId '00000000-0000-0000-0000-000000000101' -NeedDirect $record.DirectRetrieved | Should -BeTrue $record.DirectGroupObjectIds.Count | Should -Be 2 $record.DirectGroupObjectIds | Should -Contain '00000000-0000-0000-0000-0000000000a0' $record.DirectGroupObjectIds | Should -Contain '00000000-0000-0000-0000-0000000000b0' $record.DirectGroupObjectIds | Should -Not -Contain '00000000-0000-0000-0000-0000000000e0' } It 'contains a mid-pagination failure to the affected facet instead of returning a short list' { # The critical case. A truncated membership list looks exactly like a user who # left a group, and would silently reclassify them (FR-013). Mock Invoke-PersonaGraphRequest { throw 'Graph 503 after 5 attempts' } $record = Get-PersonaGroupMembership -UserObjectId '00000000-0000-0000-0000-000000000101' -NeedDirect $record.DirectRetrieved | Should -BeFalse $record.DirectFailureReason | Should -Not -BeNullOrEmpty } }