Tests/Integration/SPClean.Integration.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# .SYNOPSIS SPClean M01 — Integration Tests (real tenant required). .DESCRIPTION Tests run against a dev tenant with pre-seeded orphaned users. All tests are skipped automatically when env vars are absent. Required env vars: SPC_TENANT_NAME — e.g. "contoso" or "contoso.onmicrosoft.com" SPC_CLIENT_ID — Azure AD app registration client ID SPC_CERT_PATH — Full path to .pfx certificate file SPC_CERT_PASSWORD — Certificate password (plain text; converted to SecureString here) SPC_TEST_SITE_ORPHAN — Full URL of a site collection pre-seeded with exactly 3 orphaned users Setup requirements: - The test site (SPC_TEST_SITE_ORPHAN) must have exactly 3 users whose accounts are deleted/soft-deleted in Entra ID but still present in the SharePoint UIL. - AC-07 and AC-08 are destructive (remove, then restore). Run the full suite only once against a freshly seeded tenant; re-seed orphans before re-running. Run: $env:SPC_TENANT_NAME = 'contoso' ... (set all vars) Invoke-Pester Tests/Integration/ -Output Detailed #> # ── Skip condition — evaluated at discovery time (script level, before BeforeAll) ── $script:requiredVars = @( 'SPC_TENANT_NAME', 'SPC_CLIENT_ID', 'SPC_CERT_PATH', 'SPC_CERT_PASSWORD', 'SPC_TEST_SITE_ORPHAN' ) $script:missingVars = $script:requiredVars | Where-Object { -not (Get-Item "Env:$_" -ErrorAction SilentlyContinue) } $script:skipInteg = $script:missingVars.Count -gt 0 if ($script:skipInteg) { Write-Warning "SPClean integration tests will be SKIPPED — missing env vars: $($script:missingVars -join ', ')" } # ── Module load + shared connect ────────────────────────────────────────────── BeforeAll { Import-Module (Join-Path $PSScriptRoot '../../SPClean.psd1') -Force -ErrorAction Stop if (-not $script:skipInteg) { $certPass = ConvertTo-SecureString $env:SPC_CERT_PASSWORD -AsPlainText -Force $script:conn = Connect-SPCTenant ` -TenantName $env:SPC_TENANT_NAME ` -AuthMethod AppOnly ` -ClientId $env:SPC_CLIENT_ID ` -CertificatePath $env:SPC_CERT_PATH ` -CertificatePassword $certPass $shortName = ($env:SPC_TENANT_NAME -replace '\..*$', '') $script:testSiteUrl = $env:SPC_TEST_SITE_ORPHAN $script:cleanSiteUrl = "https://$shortName.sharepoint.com" # Isolated temp dir for report files and snapshots $ts = (Get-Date).ToString('yyyyMMddHHmmss') $script:tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "SPClean_IT_$ts" [void](New-Item -Path $script:tempDir -ItemType Directory -Force) $script:snapshotDir = Join-Path $script:tempDir 'Snapshots' # Fetch orphans once — reused by AC-03, AC-05, AC-07 $script:orphans = @(Get-SPCOrphanedUser -SiteUrl $script:testSiteUrl) } } AfterAll { if (-not $script:skipInteg) { Disconnect-SPCTenant -ErrorAction SilentlyContinue if ($script:tempDir -and (Test-Path $script:tempDir)) { Remove-Item -Path $script:tempDir -Recurse -Force -ErrorAction SilentlyContinue } } } # ───────────────────────────────────────────────────────────────────────────── # AC-01: Interactive auth (manual only — device-code requires browser interaction) # ───────────────────────────────────────────────────────────────────────────── Describe 'AC-01: Interactive auth' { It 'AC-01: [manual] Connect-SPCTenant interactive flow returns SPC.ConnectionInfo' -Skip { # Run manually: Connect-SPCTenant -TenantName contoso # Do NOT automate — requires browser or device-code user interaction. $result = Connect-SPCTenant -TenantName $env:SPC_TENANT_NAME $result.PSObject.TypeNames | Should -Contain 'SPC.ConnectionInfo' $result.AuthMethod | Should -Be 'Interactive' } } # ───────────────────────────────────────────────────────────────────────────── # AC-02: AppOnly certificate authentication # ───────────────────────────────────────────────────────────────────────────── Describe 'AC-02: AppOnly cert auth' { It 'AC-02: Connect-SPCTenant returns SPC.ConnectionInfo' -Skip:$script:skipInteg { $script:conn.PSObject.TypeNames | Should -Contain 'SPC.ConnectionInfo' } It 'AC-02: AuthMethod is AppOnly' -Skip:$script:skipInteg { $script:conn.AuthMethod | Should -Be 'AppOnly' } It 'AC-02: TenantName is populated and matches configured tenant' -Skip:$script:skipInteg { $script:conn.TenantName | Should -Not -BeNullOrEmpty } It 'AC-02: ConnectedAt is a valid UTC datetime within last 5 minutes' -Skip:$script:skipInteg { $script:conn.ConnectedAt | Should -BeOfType [datetime] $script:conn.ConnectedAt | Should -BeGreaterThan (Get-Date).AddMinutes(-5).ToUniversalTime() } It 'AC-02: ExpiresAt is in the future' -Skip:$script:skipInteg { $script:conn.ExpiresAt | Should -BeGreaterThan (Get-Date).ToUniversalTime() } It 'AC-12: SPC.ConnectionInfo properties contain no credential strings' -Skip:$script:skipInteg { $propValues = $script:conn.PSObject.Properties | ForEach-Object { [string]$_.Value } $propValues | ForEach-Object { $_ | Should -Not -Match 'password|secret|pfx|credential' } } } # ───────────────────────────────────────────────────────────────────────────── # AC-03: Orphan detection against real tenant # ───────────────────────────────────────────────────────────────────────────── Describe 'AC-03: Orphan detection — real tenant' { It 'AC-03: detects exactly 3 orphaned users on the pre-seeded test site' -Skip:$script:skipInteg { $script:orphans | Should -HaveCount 3 } It 'AC-03: all output objects carry TypeName SPC.OrphanedUser' -Skip:$script:skipInteg { $script:orphans | ForEach-Object { $_.PSObject.TypeNames | Should -Contain 'SPC.OrphanedUser' } } It 'AC-03: OrphanType is a valid SRS enum value' -Skip:$script:skipInteg { $valid = 'Deleted', 'SoftDeleted', 'Disabled', 'GuestOrphaned' $script:orphans | ForEach-Object { $_.OrphanType | Should -BeIn $valid } } It 'AC-03: RiskLevel is HIGH, MEDIUM, or LOW' -Skip:$script:skipInteg { $script:orphans | ForEach-Object { $_.RiskLevel | Should -BeIn 'HIGH', 'MEDIUM', 'LOW' } } It 'AC-03: SiteUrl on every result matches the scanned site' -Skip:$script:skipInteg { $script:orphans | ForEach-Object { $_.SiteUrl | Should -Be $script:testSiteUrl } } It 'AC-03: all required SRS output properties are present' -Skip:$script:skipInteg { $required = 'SiteUrl','SiteTitle','UserId','LoginName','DisplayName','Email','UPN', 'OrphanType','RiskLevel','HasDirectPermissions','GroupMemberships', 'LastActivityDate','DetectedAt' foreach ($prop in $required) { $script:orphans[0].PSObject.Properties.Name | Should -Contain $prop } } } # ───────────────────────────────────────────────────────────────────────────── # AC-04: Clean site returns 0 results # ───────────────────────────────────────────────────────────────────────────── Describe 'AC-04: Clean site — 0 results' { It 'AC-04: tenant root site returns 0 orphaned users' -Skip:$script:skipInteg { # Uses the tenant root URL as a conventionally clean site. # If this fails, the root site has unexpected orphan accounts — choose a different clean site. $result = @(Get-SPCOrphanedUser -SiteUrl $script:cleanSiteUrl) $result | Should -HaveCount 0 } } # ───────────────────────────────────────────────────────────────────────────── # AC-05: HTML report generated from real scan results # ───────────────────────────────────────────────────────────────────────────── Describe 'AC-05: HTML report' { BeforeAll { if (-not $script:skipInteg -and $script:orphans.Count -gt 0) { $htmlPath = Join-Path $script:tempDir 'report_ac05.html' $script:reportResult = $script:orphans | Export-SPCReport -Format HTML -Path $htmlPath $script:reportHtml = if (Test-Path $htmlPath) { Get-Content $htmlPath -Raw } else { '' } } } It 'AC-05: Export-SPCReport returns SPC.ReportResult' -Skip:$script:skipInteg { $script:reportResult.PSObject.TypeNames | Should -Contain 'SPC.ReportResult' } It 'AC-05: HTML report file exists at FilePath' -Skip:$script:skipInteg { Test-Path $script:reportResult.FilePath | Should -Be $true } It 'AC-05: TotalOrphansReported equals detected orphan count' -Skip:$script:skipInteg { $script:reportResult.TotalOrphansReported | Should -Be $script:orphans.Count } It 'AC-05: HTML contains HIGH risk badge color (#dc3545 per SRS 3.3.1)' -Skip:$script:skipInteg { $script:reportHtml | Should -Match '#dc3545' } It 'AC-05: HTML contains MEDIUM risk badge color (#ffc107 per SRS 3.3.1)' -Skip:$script:skipInteg { $script:reportHtml | Should -Match '#ffc107' } It 'AC-05: HTML file is non-empty and well-formed' -Skip:$script:skipInteg { $script:reportHtml.Length | Should -BeGreaterThan 500 $script:reportHtml | Should -Match '<html' $script:reportHtml | Should -Match '</html>' } } # ───────────────────────────────────────────────────────────────────────────── # AC-07: Remove + CreateSnapshot (DESTRUCTIVE — modifies the test tenant) # ───────────────────────────────────────────────────────────────────────────── Describe 'AC-07: Remove + CreateSnapshot' { BeforeAll { if (-not $script:skipInteg -and $script:orphans.Count -gt 0) { $script:removeResults = @( $script:orphans | Remove-SPCOrphanedUser ` -CreateSnapshot ` -SnapshotPath $script:snapshotDir ` -Force ` -Confirm:$false ) $script:snapshotFiles = @( Get-ChildItem -Path $script:snapshotDir -Filter '*.json' -ErrorAction SilentlyContinue ) } else { $script:removeResults = @() $script:snapshotFiles = @() } } It 'AC-07: Remove-SPCOrphanedUser returns SPC.RemovalResult objects' -Skip:$script:skipInteg { $script:removeResults | ForEach-Object { $_.PSObject.TypeNames | Should -Contain 'SPC.RemovalResult' } } It 'AC-07: at least one user was removed from the UIL' -Skip:$script:skipInteg { @($script:removeResults | Where-Object { $_.RemovedFromUIL }).Count | Should -BeGreaterThan 0 } It 'AC-07: snapshot JSON files created in SnapshotPath' -Skip:$script:skipInteg { $script:snapshotFiles.Count | Should -BeGreaterThan 0 } It 'AC-07: snapshot file contains all SRS 6.2 required fields' -Skip:$script:skipInteg { $snap = Get-Content $script:snapshotFiles[0].FullName -Raw | ConvertFrom-Json $snap.snapshotVersion | Should -Not -BeNullOrEmpty $snap.createdAt | Should -Not -BeNullOrEmpty $snap.tenantName | Should -Not -BeNullOrEmpty $snap.siteUrl | Should -Not -BeNullOrEmpty $snap.user | Should -Not -Be $null $snap.user.loginName | Should -Not -BeNullOrEmpty $snap.user.upn | Should -Not -BeNullOrEmpty $snap.permissions | Should -Not -Be $null } It 'AC-07: re-scan of test site returns 0 orphans after removal' -Skip:$script:skipInteg { $reScan = @(Get-SPCOrphanedUser -SiteUrl $script:testSiteUrl) $reScan | Should -HaveCount 0 } } # ───────────────────────────────────────────────────────────────────────────── # AC-08: Restore from snapshot (runs after AC-07 which must have created snapshots) # ───────────────────────────────────────────────────────────────────────────── Describe 'AC-08: Restore from snapshot' { BeforeAll { if (-not $script:skipInteg -and $script:snapshotFiles.Count -gt 0) { $firstSnap = $script:snapshotFiles | Select-Object -First 1 $script:restoreResult = Restore-SPCOrphanedUser ` -SnapshotPath $firstSnap.FullName ` -Confirm:$false } else { $script:restoreResult = $null } } It 'AC-08: Restore-SPCOrphanedUser returns SPC.RestoreResult' -Skip:$script:skipInteg { $script:restoreResult | Should -Not -Be $null $script:restoreResult.PSObject.TypeNames | Should -Contain 'SPC.RestoreResult' } It 'AC-08: Status is Success or PartialSuccess' -Skip:$script:skipInteg { $script:restoreResult.Status | Should -BeIn 'Success', 'PartialSuccess' } It 'AC-08: PermissionsRestored count is a non-negative integer' -Skip:$script:skipInteg { $script:restoreResult.PermissionsRestored | Should -BeGreaterOrEqual 0 } } # ───────────────────────────────────────────────────────────────────────────── # AC-PERF-01: Single-site scan must complete in under 30 seconds (SRS 3.2.1) # ───────────────────────────────────────────────────────────────────────────── Describe 'AC-PERF-01: Performance' { It 'AC-PERF-01: single-site Get-SPCOrphanedUser completes in under 30 seconds' -Skip:$script:skipInteg { $elapsed = (Measure-Command { Get-SPCOrphanedUser -SiteUrl $script:testSiteUrl -ErrorAction SilentlyContinue | Out-Null }).TotalSeconds Write-Host " [PERF] Elapsed: $([math]::Round($elapsed, 2))s" $elapsed | Should -BeLessThan 30 } } |