Tests/Unit/Get-SPCGuestAccess.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { $script:sut = "$PSScriptRoot\..\..\Public\Scan\Get-SPCGuestAccess.ps1" . $PSScriptRoot\..\..\Public\Scan\Get-SPCGuestAccess.ps1 function Test-SPCConnection {} function Connect-PnPOnline {} function Get-PnPUser {} function Invoke-PnPSPRestMethod {} function Get-PnPGroup {} function Invoke-SPCGraphBatch {} function Get-PnPAccessToken {} function Get-PnPTenantSite {} function Start-Sleep {} } Describe "Get-SPCGuestAccess" { BeforeEach { $script:SPCContext = [PSCustomObject]@{ TenantName = 'tenant' AuthMethod = 'Interactive' PnPContext = $null GraphAccessToken = 'mock_token' } Mock Get-PnPAccessToken { return "mock_token" } } Context "Scanning and Filtering" { It "TC-FUNC-001 (AC-01): Should successfully scan a specific Site when -SiteUrl is provided" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "mock_token" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'guest1@ext.com'; LoginName = 'i:0#.f|membership|guest1#EXT#@domain.com'; PrincipalType = 'Guest'; IsSiteAdmin = $true } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { return @( [PSCustomObject]@{ id = 'guest1#EXT#@domain.com' status = 200 body = [PSCustomObject]@{ value = @( [PSCustomObject]@{ userPrincipalName = 'guest1#EXT#@domain.com' signInActivity = [PSCustomObject]@{ lastSignInDateTime = (Get-Date).AddDays(-10).ToString('o') } } ) } } ) } $result = Get-SPCGuestAccess -SiteUrl "https://tenant.sharepoint.com/sites/Test" $result.Count | Should -Be 1 $result[0].GuestEmail | Should -Be 'guest1@ext.com' Assert-MockCalled Connect-PnPOnline -Times 1 } It "TC-FUNC-002 (AC-01): Should iterate through all Site Collections and scan entire Tenant when -SiteUrl is not provided" { Mock Test-SPCConnection {} Mock Get-PnPTenantSite { return @( [PSCustomObject]@{ Url = 'https://tenant.sharepoint.com/sites/Site1' }, [PSCustomObject]@{ Url = 'https://tenant.sharepoint.com/sites/Site2' } ) } Mock Get-PnPAccessToken { return "mock_token" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @() } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { return @() } $result = Get-SPCGuestAccess Assert-MockCalled Get-PnPTenantSite -Times 1 Assert-MockCalled Connect-PnPOnline -Times 2 } It "TC-FUNC-003: Should only filter Guest accounts (PrincipalType == Guest OR LoginName contains '#EXT#')" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "mock_token" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'guest1@ext.com'; LoginName = 'i:0#.f|membership|guest1#EXT#@domain.com'; PrincipalType = 'Guest'; IsSiteAdmin = $false }, [PSCustomObject]@{ Email = 'user1@domain.com'; LoginName = 'user1@domain.com'; PrincipalType = 'User'; IsSiteAdmin = $false }, [PSCustomObject]@{ Email = ''; LoginName = 'group1'; PrincipalType = 'SharePointGroup'; IsSiteAdmin = $false } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { return @() } $result = Get-SPCGuestAccess -SiteUrl "https://test" $result.Count | Should -Be 1 $result[0].GuestEmail | Should -Be 'guest1@ext.com' } } Context "Risk Level Scoring" { It "TC-FUNC-004 (AC-02): Should assign RiskLevel HIGH if Guest has Full Control or Owner permissions" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "mock_token" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'guest@ext.com'; LoginName = 'guest#EXT#'; PrincipalType = 'Guest'; IsSiteAdmin = $true } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { return @( [PSCustomObject]@{ id = 'guest#EXT#' status = 200 body = [PSCustomObject]@{ value = @( [PSCustomObject]@{ userPrincipalName = 'guest#EXT#' signInActivity = [PSCustomObject]@{ lastSignInDateTime = (Get-Date).AddDays(-10).ToString('o') } } ) } } ) } $result = Get-SPCGuestAccess -SiteUrl "https://test" $result[0].RiskLevel | Should -Be 'HIGH' } It "TC-FUNC-005 (AC-02): Should assign RiskLevel HIGH if Guest is Inactive > 180 days" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "mock_token" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'guest@ext.com'; LoginName = 'guest#EXT#'; PrincipalType = 'Guest'; IsSiteAdmin = $false } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { return @( [PSCustomObject]@{ id = 'guest#EXT#' status = 200 body = [PSCustomObject]@{ value = @( [PSCustomObject]@{ userPrincipalName = 'guest#EXT#' signInActivity = [PSCustomObject]@{ lastSignInDateTime = (Get-Date).AddDays(-185).ToString('o') } } ) } } ) } $result = Get-SPCGuestAccess -SiteUrl "https://test" $result[0].RiskLevel | Should -Be 'HIGH' } It "TC-FUNC-006 (AC-02): Should assign RiskLevel MEDIUM if Guest has Edit or Write permissions via SP Group" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "mock_token" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'guest@ext.com'; LoginName = 'guest#EXT#'; PrincipalType = 'Guest'; IsSiteAdmin = $false } ) } Mock Get-PnPGroup { return @( [PSCustomObject]@{ LoginName = 'Members Group' Users = @( [PSCustomObject]@{ LoginName = 'guest#EXT#' } ) } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @( [PSCustomObject]@{ Member = [PSCustomObject]@{ LoginName = 'Members Group' } RoleDefinitionBindings = @( [PSCustomObject]@{ Name = 'Edit' } ) } ) } } Mock Invoke-SPCGraphBatch { return @( [PSCustomObject]@{ id = 'guest#EXT#' status = 200 body = [PSCustomObject]@{ value = @( [PSCustomObject]@{ userPrincipalName = 'guest#EXT#' signInActivity = [PSCustomObject]@{ lastSignInDateTime = (Get-Date).AddDays(-10).ToString('o') } } ) } } ) } $result = Get-SPCGuestAccess -SiteUrl "https://test" $result[0].RiskLevel | Should -Be 'MEDIUM' } It "TC-FUNC-007 (AC-02): Should assign RiskLevel MEDIUM if Guest is Inactive between 91 and 180 days" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "mock_token" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'guest@ext.com'; LoginName = 'guest#EXT#'; PrincipalType = 'Guest'; IsSiteAdmin = $false } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { return @( [PSCustomObject]@{ id = 'guest#EXT#' status = 200 body = [PSCustomObject]@{ value = @( [PSCustomObject]@{ userPrincipalName = 'guest#EXT#' signInActivity = [PSCustomObject]@{ lastSignInDateTime = (Get-Date).AddDays(-100).ToString('o') } } ) } } ) } $result = Get-SPCGuestAccess -SiteUrl "https://test" $result[0].RiskLevel | Should -Be 'MEDIUM' } } Context "Output Results" { It "TC-FUNC-009 (AC-03): Should return object containing InvitedBy from Graph API" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "mock_token" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'guest@ext.com'; LoginName = 'i:0#.f|membership|guest_gmail.com#EXT#@tenant.onmicrosoft.com'; PrincipalType = 'Guest'; IsSiteAdmin = $false } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { return @( [PSCustomObject]@{ id = 'guest_gmail.com#EXT#@tenant.onmicrosoft.com' status = 200 body = [PSCustomObject]@{ value = @( [PSCustomObject]@{ userPrincipalName = 'guest_gmail.com#EXT#@tenant.onmicrosoft.com' signInActivity = [PSCustomObject]@{ lastSignInDateTime = (Get-Date).AddDays(-10).ToString('o') } invitedBy = [PSCustomObject]@{ user = [PSCustomObject]@{ email = 'admin@tenant.com' } } } ) } } ) } $result = Get-SPCGuestAccess -SiteUrl "https://test" $result[0].InvitedBy | Should -Be 'admin@tenant.com' } } Context "Error Handling" { It "TC-NEG-001 (AC-04): Should not crash script and must assign LastAccess = 'N/A' when Guest account is soft-deleted or does not exist in Entra ID" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "mock_token" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'notfound@ext.com'; LoginName = 'notfound#EXT#'; PrincipalType = 'Guest'; IsSiteAdmin = $false } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { throw "Graph API Error 404 Not Found" } $result = Get-SPCGuestAccess -SiteUrl "https://test" 2>$null $result[0].LastAccess | Should -Be 'N/A' } It "TC-NEG-004: Should abort graph queries on non-429 errors (e.g., 401 Unauthorized)" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "MockToken" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'user1@ext.com'; LoginName = 'user1#EXT#'; PrincipalType = 'Guest'; IsSiteAdmin = $false }, [PSCustomObject]@{ Email = 'user2@ext.com'; LoginName = 'user2#EXT#'; PrincipalType = 'Guest'; IsSiteAdmin = $false } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { throw "401 Unauthorized" } # Since there's 2 users, if we had batch size 1 (hypothetically) it would loop twice. # We mock to ensure Invoke-SPCGraphBatch is only called once and breaks. $result = Get-SPCGuestAccess -SiteUrl "https://test" 2>$null Assert-MockCalled Invoke-SPCGraphBatch -Times 1 } } Context "Data Protection and Pester Rules" { It "TC-SEC-001 (AC-12): Should not leak any credential, token or password in Verbose and Error streams" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "MockToken" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @() } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Invoke-SPCGraphBatch { return @() } $stream = { Get-SPCGuestAccess -SiteUrl "https://test" -Verbose } *>&1 $output = $stream | Out-String $output -match 'password|secret|token|pfx|credential' | Should -Be $false } } Context "Scalability and API Throttling" { It "TC-PERF-001: Should retry using Exponential Backoff up to 5 times when encountering Graph API 429 Throttling error" { Mock Test-SPCConnection {} Mock Get-PnPAccessToken { return "MockToken" } Mock Connect-PnPOnline {} Mock Get-PnPUser { return @( [PSCustomObject]@{ Email = 'guest@ext.com'; LoginName = 'guest#EXT#'; PrincipalType = 'Guest'; IsSiteAdmin = $false } ) } Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } } Mock Get-PnPGroup { return @() } Mock Start-Sleep {} Mock Invoke-SPCGraphBatch { throw "429 Too Many Requests" } $result = Get-SPCGuestAccess -SiteUrl "https://test" 2>$null Assert-MockCalled Invoke-SPCGraphBatch -Times 5 Assert-MockCalled Start-Sleep -Times 5 } } } |