diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml new file mode 100644 index 0000000..a430d86 --- /dev/null +++ b/.github/workflows/pester.yml @@ -0,0 +1,83 @@ +# This is the SPSCleanDependencies CI Pester workflow to run tests on pull requests + +name: SPSCleanDependencies CI Pester Tests + +on: + pull_request: + branches: + - main + paths: + - 'scripts/**' + - 'tests/**' + +jobs: + test: + name: Run Pester Tests + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Pester + shell: pwsh + run: | + Install-Module -Name Pester -MinimumVersion 5.3.0 -Force -SkipPublisherCheck -Scope CurrentUser + Import-Module Pester + + - name: Run Pester Tests + shell: pwsh + run: | + $config = New-PesterConfiguration + $config.Run.Path = './tests' + $config.Run.Exit = $true + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = './test-results.xml' + $config.Output.Verbosity = 'Detailed' + + Invoke-Pester -Configuration $config + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: test-results.xml + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action/composite@v2 + if: always() + with: + files: | + test-results.xml + check_name: Pester Test Results + + code-quality: + name: Code Quality Check + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install PSScriptAnalyzer + shell: pwsh + run: | + Install-Module -Name PSScriptAnalyzer -Force -SkipPublisherCheck -Scope CurrentUser + + - name: Run PSScriptAnalyzer + shell: pwsh + run: | + $scriptFiles = Get-ChildItem -Path ./scripts -Include '*.ps1', '*.psm1' -Recurse + + $results = $scriptFiles | ForEach-Object { + Invoke-ScriptAnalyzer -Path $_.FullName -Severity Error,Warning -Settings ./PSScriptAnalyzerSettings.psd1 + } + + if ($results) { + $results | Format-Table -AutoSize + Write-Error "PSScriptAnalyzer found issues" + exit 1 + } else { + Write-Host "No issues found by PSScriptAnalyzer" + } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eb03c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Ignore vscode files +.vscode/ + +# Ignore DS_Store files +**/.DS_Store \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f09f587..cf5e6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,39 @@ The format is based on and uses the types of changes according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-06-11 + +### Added + +- SPSCleanDependencies.util.psm1: + - Add `Get-SQLMissingWebPartInfo` helper to resolve missing WebPart class IDs to per-page locations (SiteID/WebID/ListID/DirName/LeafName). + - Add `Remove-SPSMissingWebPart` cleanup function (uses `GetLimitedWebPartManager` and temporarily clears the site `ReadOnly` flag). + - Extend `SPMissingWebPartInfo` class with `ClassName`, `StorageKey`, `SiteID`, `WebID`, `ListID`, `DirName`, `LeafName`. + - All `Remove-SPS*` functions now declare `[CmdletBinding(SupportsShouldProcess = $true)]` and gate destructive calls with `$PSCmdlet.ShouldProcess`, enabling `-WhatIf` / `-Confirm` for every cleanup branch. + - Import-time prelude (admin check, `powercfg`, SharePoint snap-in load) is now gated behind the `SPSCD_SKIP_PRELUDE` environment variable so the module can be imported on non-SharePoint hosts (CI, Pester) without elevation or SharePoint installed. Behaviour on a real SharePoint farm is unchanged. + +- SPSCleanDependencies.ps1: + - Implement the `MissingWebPart` cleanup branch (previously a no-op). + - Implement the `SiteOrphan` cleanup branch by wiring up the existing `Remove-SPSOrphanedSite` function. + +- Pester test suite under `tests/`: + - `tests/SPSCleanDependencies.Tests.ps1` - script-level tests (metadata, parameters, module imports, Clean branch wiring). + - `tests/Modules/SPSCleanDependencies.util.Tests.ps1` - module-level tests (public/SQL function contracts, class shapes, safety net for empty `StorageKey`, `SupportsShouldProcess` coverage on every `Remove-SPS*` function). + +- CI: + - `.github/workflows/pester.yml` - runs Pester 5.3+ on `windows-latest` for pull requests to `main`, plus a `PSScriptAnalyzer` code-quality job. + +### Fixed + +- SPSCleanDependencies.util.psm1: + - Replace `Write-Host` in `Remove-SPSMissingSetupFile` with `Write-Output` (PSScriptAnalyzer `PSAvoidUsingWriteHost`). + +- CI / repo configuration: + - Add `PSScriptAnalyzerSettings.psd1` at the repo root, excluding `PSUseSingularNouns` so the public `Get-SPSMissingServerDependencies` function can keep its current (plural) name for backward compatibility. The CI workflow now invokes `Invoke-ScriptAnalyzer` with `-Settings ./PSScriptAnalyzerSettings.psd1`. + +- tests/Modules/SPSCleanDependencies.util.Tests.ps1: + - Surface real `Import-Module` failures instead of silently swallowing them with `-ErrorAction SilentlyContinue`, which had been hiding the actual cause of cascading test failures on CI. + ## [1.1.0] - 2025-10-21 ### Changed diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..0dae8e2 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,12 @@ +@{ + # Settings consumed by Invoke-ScriptAnalyzer in CI and the local lint task. + # + # PSUseSingularNouns is excluded because the public function + # Get-SPSMissingServerDependencies must keep its current (plural) name + # for backward compatibility with existing callers, documentation, and + # the wiki. Attribute-based per-function suppression is unreliable for + # this rule, so we exclude it project-wide. + ExcludeRules = @( + 'PSUseSingularNouns' + ) +} diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 9fdc835..8c25f68 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,22 +1,27 @@ # SPSCleanDependencies - Release Notes -## [1.1.0] - 2025-10-21 +## [1.2.0] - 2026-06-11 -### Changed +### Added + +- SPSCleanDependencies.util.psm1: + - Add `Get-SQLMissingWebPartInfo` helper to resolve missing WebPart class IDs to per-page locations. + - Add `Remove-SPSMissingWebPart` cleanup function (uses `GetLimitedWebPartManager` and temporarily clears the site `ReadOnly` flag). + - Extend `SPMissingWebPartInfo` class with location fields (`StorageKey`, `SiteID`, `WebID`, `ListID`, `DirName`, `LeafName`). + - All `Remove-SPS*` functions now support `-WhatIf` / `-Confirm` via `SupportsShouldProcess`. + - Import-time prelude (admin check, `powercfg`, SharePoint snap-in load) is now gated behind the `SPSCD_SKIP_PRELUDE` environment variable so the module can be imported on CI / non-SharePoint hosts. - SPSCleanDependencies.ps1: - - Resolve Invoke-Sqlcmd does not work because sqlserver is not present [issue #2](https://github.com/luigilink/SPSCleanDependencies/issues/2) - - Resolve Performing the operation "Set-SPSite" on target "*sitemaster-*" [issue #3](https://github.com/luigilink/SPSCleanDependencies/issues/3) + - Implement the `MissingWebPart` cleanup branch (previously a no-op). + - Implement the `SiteOrphan` cleanup branch by wiring up the existing `Remove-SPSOrphanedSite` function. + +- Pester test suite under `tests/` covering the script and helper module, including `SupportsShouldProcess` coverage on every `Remove-SPS*` function. -- Wiki Documentation in repository - Update : - - wiki/Home.md - - wiki/Getting-Started.md - - wiki/Usage.md +- `.github/workflows/pester.yml` CI workflow running Pester 5.3+ and `PSScriptAnalyzer` on `windows-latest`. -- Issue Templates files: - - 1_bug_report.yml Update version +### Fixed -- README.md - - Add Requirements for PowerShell 5 and SqlServer PowerShell Module +- Replace `Write-Host` with `Write-Output` in `Remove-SPSMissingSetupFile` and clear remaining `PSScriptAnalyzer` warnings via a new `PSScriptAnalyzerSettings.psd1` at the repo root (excluding `PSUseSingularNouns` to preserve the public `Get-SPSMissingServerDependencies` name). +- Stop hiding real `Import-Module` failures in the module Pester tests so future regressions surface immediately. A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/scripts/Modules/SPSCleanDependencies.util.psm1 b/scripts/Modules/SPSCleanDependencies.util.psm1 index 3609367..7c75832 100644 --- a/scripts/Modules/SPSCleanDependencies.util.psm1 +++ b/scripts/Modules/SPSCleanDependencies.util.psm1 @@ -1,13 +1,7 @@ -# Ensure the script is running with administrator privileges -if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { - Throw "Administrator rights are required. Please re-run this script as an Administrator." -} -# Setting power management plan to High Performance" -Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' -NoNewWindow function Get-SPSInstalledProductVersion { [OutputType([System.Version])] param () - + $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 if ($null -eq $fullPath) { @@ -17,25 +11,38 @@ function Get-SPSInstalledProductVersion { return (Get-Command $fullPath).FileVersionInfo } } -# Load SharePoint Powershell Snapin or Import-Module -try { - $installedVersion = Get-SPSInstalledProductVersion - if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { - if ($null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) { - Add-PSSnapin Microsoft.SharePoint.PowerShell - } + +# Import-time prelude (admin check, power plan, SharePoint snap-in load). +# Skipped when $env:SPSCD_SKIP_PRELUDE is set, so the module can be imported on +# CI / non-SharePoint hosts (e.g. Pester) without elevation or SharePoint installed. +if (-not $env:SPSCD_SKIP_PRELUDE) { + # Ensure the script is running with administrator privileges + if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { + Throw "Administrator rights are required. Please re-run this script as an Administrator." } - else { - Import-Module SharePointServer -Verbose:$false -WarningAction SilentlyContinue -DisableNameChecking + # Setting power management plan to High Performance" + Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' -NoNewWindow + + # Load SharePoint Powershell Snapin or Import-Module + try { + $installedVersion = Get-SPSInstalledProductVersion + if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { + if ($null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) { + Add-PSSnapin Microsoft.SharePoint.PowerShell + } + } + else { + Import-Module SharePointServer -Verbose:$false -WarningAction SilentlyContinue -DisableNameChecking + } } -} -catch { - # Handle errors during retrieval of Installed Product Version - $catchMessage = @" + catch { + # Handle errors during retrieval of Installed Product Version + $catchMessage = @" Failed to get installed Product Version for $($env:COMPUTERNAME) Exception: $($_.Exception.Message) "@ - Write-Error -Message $catchMessage + Write-Error -Message $catchMessage + } } # Initialize jSON Object @@ -69,6 +76,13 @@ class SPMissingWebPartInfo { [System.String]$WebPartID [System.String]$Message [System.String]$Remedy + [System.String]$ClassName + [System.String]$StorageKey + [System.String]$SiteID + [System.String]$WebID + [System.String]$ListID + [System.String]$DirName + [System.String]$LeafName } class SPMissingSetupFileInfo { [System.String]$Database @@ -217,6 +231,64 @@ FROM EventReceivers (NOLOCK) where Assembly = '$($AssemblyInfo)' return $tbSQLmissingAssemblies } +function Get-SQLMissingWebPartInfo { + param + ( + [Parameter()] + [System.String] + $DatabaseName, + + [Parameter()] + [System.String] + $DatabaseServer, + + [Parameter()] + [System.String] + $ClassID + ) + + class SQLMissingWebPartInfo { + [System.String]$StorageKey + [System.String]$ClassName + [System.String]$SiteID + [System.String]$WebID + [System.String]$ListID + [System.String]$DirName + [System.String]$LeafName + } + $tbSQLmissingWebParts = New-Object -TypeName System.Collections.ArrayList + try { + $sqlQuery = + @" +USE $($DatabaseName) +SELECT wp.tp_ID, d.SiteId, d.WebId, d.ListId, d.DirName, d.LeafName, wp.tp_Class +FROM AllDocs d WITH (NOLOCK) +INNER JOIN AllWebParts wp WITH (NOLOCK) ON wp.tp_PageUrlID = d.Id +INNER JOIN AllWebs w WITH (NOLOCK) ON d.WebId = w.id +WHERE wp.tp_WebPartTypeId = '$($ClassID)' +"@ + + $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` + -ServerInstance "$($DatabaseServer)" + + foreach ($invokeSQLQuery in $invokeSQLQueries) { + [void]$tbSQLmissingWebParts.Add([SQLMissingWebPartInfo]@{ + StorageKey = $invokeSQLQuery.tp_ID; + ClassName = $invokeSQLQuery.tp_Class; + SiteID = $invokeSQLQuery.SiteId; + WebID = $invokeSQLQuery.WebId; + ListID = $invokeSQLQuery.ListId; + DirName = $invokeSQLQuery.DirName; + LeafName = $invokeSQLQuery.LeafName; + }) + } + } + catch { + return $_ + } + return $tbSQLmissingWebParts +} + function Get-SQLMissingConfiguration { param ( @@ -298,13 +370,37 @@ function Get-SPSMissingServerDependencies { if ($null -ne $missingWebParts) { foreach ($missingWebPart in $missingWebParts) { $webPartID = ([regex]::Matches($missingWebPart.Message, '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}')).value - [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ - Database = "$($spContentDB.Name)"; - Category = $missingWebPart.Category; - WebPartID = $webPartID - Message = $missingWebPart.Message; - Remedy = $missingWebPart.Remedy; - }) + $sqlMissingWebParts = Get-SQLMissingWebPartInfo -DatabaseName "$($spContentDB.Name)" ` + -DatabaseServer "$($spContentDB.Server)" ` + -ClassID "$($webPartID)" + + if ($null -ne $sqlMissingWebParts -and $sqlMissingWebParts.Count -gt 0) { + foreach ($sqlMissingWebPart in $sqlMissingWebParts) { + [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingWebPart.Category; + WebPartID = $webPartID; + Message = $missingWebPart.Message; + Remedy = $missingWebPart.Remedy; + ClassName = $sqlMissingWebPart.ClassName; + StorageKey = $sqlMissingWebPart.StorageKey; + SiteID = $sqlMissingWebPart.SiteID; + WebID = $sqlMissingWebPart.WebID; + ListID = $sqlMissingWebPart.ListID; + DirName = $sqlMissingWebPart.DirName; + LeafName = $sqlMissingWebPart.LeafName; + }) + } + } + else { + [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingWebPart.Category; + WebPartID = $webPartID; + Message = $missingWebPart.Message; + Remedy = $missingWebPart.Remedy; + }) + } } } if ($null -ne $missingSetupFiles) { @@ -445,6 +541,7 @@ function Get-SPSMissingServerDependencies { } function Remove-SPSMissingFeature { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -475,7 +572,9 @@ function Remove-SPSMissingFeature { ForEach ($web in $Site.AllWebs) { if ($web.Features[$featureID]) { Write-Output "`nFound Feature $featureID in web:"$Web.Url"`nRemoving feature" - $web.Features.Remove($featureID, $true) + if ($PSCmdlet.ShouldProcess($Web.Url, "Remove feature $featureID")) { + $web.Features.Remove($featureID, $true) + } } else { Write-Output "`nDid not find feature $featureID in web:" $Web.Url @@ -484,7 +583,9 @@ function Remove-SPSMissingFeature { #Remove the feature from the site collection if ($Site.Features[$featureID]) { Write-Output "`nFound feature $featureID in site:"$site.Url"`nRemoving Feature" - $site.Features.Remove($featureID, $true) + if ($PSCmdlet.ShouldProcess($site.Url, "Remove feature $featureID")) { + $site.Features.Remove($featureID, $true) + } } else { Write-Output "Did not find feature $featureID in site:" $site.Url @@ -500,6 +601,7 @@ function Remove-SPSMissingFeature { } function Remove-SPSMissingSetupFile { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -537,9 +639,11 @@ function Remove-SPSMissingSetupFile { $file = $web.GetFile([GUID]$FileID) if ($null -ne $file.ServerRelativeUrl) { $filelocation = "{0}{1}" -f ($site.WebApplication.Url).TrimEnd("/"), $file.ServerRelativeUrl - Write-Host "Found file location:" $filelocation + Write-Output "Found file location: $filelocation" #Delete the file, the Delete() method bypasses the recycle bin - $file.Delete() + if ($PSCmdlet.ShouldProcess($filelocation, "Delete setup file $FileID")) { + $file.Delete() + } $web.dispose() $site.dispose() } @@ -560,7 +664,106 @@ function Remove-SPSMissingSetupFile { } } +function Remove-SPSMissingWebPart { + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter()] + [System.String] + $Database, + + [Parameter()] + [System.String] + $WebPartID, + + [Parameter()] + [System.String] + $StorageKey, + + [Parameter()] + [System.String] + $SiteID, + + [Parameter()] + [System.String] + $WebID, + + [Parameter()] + [System.String] + $DirName, + + [Parameter()] + [System.String] + $LeafName + ) + + try { + Write-Output '-----------------------------------------------' + Write-Output 'Removing Missing WebPart Dependencies of:' + Write-Output " * Database: $Database" + Write-Output " * WebPartID (class): $WebPartID" + Write-Output " * StorageKey: $StorageKey" + Write-Output " * SiteID: $SiteID" + Write-Output " * WebID: $WebID" + Write-Output " * Page: $DirName/$LeafName" + Write-Output '-----------------------------------------------' + + if ([string]::IsNullOrEmpty($StorageKey)) { + Write-Output 'StorageKey is empty - no specific WebPart instance to remove. Re-run the audit to collect per-page locations.' + return + } + + $site = Get-SPSite -Limit All -Identity $SiteID -ErrorAction SilentlyContinue + if ($null -ne $site) { + $isSiteReadOnly = $site.ReadOnly + $web = Get-SPWeb -Identity $WebID -Site $site -Limit ALL -ErrorAction SilentlyContinue + if ($null -ne $web) { + $webAppUrl = ($site.WebApplication.Url).TrimEnd('/') + $pageUrl = "{0}/{1}/{2}" -f $webAppUrl, $DirName, $LeafName + Write-Output "Page URL: $pageUrl" + + if ($isSiteReadOnly) { + Write-Output 'ReadOnly flag temporarily removed from site' + $site.ReadOnly = $false + } + + try { + $spWebPartManager = $web.GetLimitedWebPartManager($pageUrl, [System.Web.UI.WebControls.WebParts.PersonalizationScope]::Shared) + $webPartToDelete = $spWebPartManager.WebParts | Where-Object -FilterScript { $_.StorageKey -eq $StorageKey } + if ($null -ne $webPartToDelete) { + Write-Output "Removing WebPart with StorageKey $StorageKey from page" + if ($PSCmdlet.ShouldProcess($pageUrl, "Delete WebPart with StorageKey $StorageKey")) { + $spWebPartManager.DeleteWebPart($spWebPartManager.WebParts[$webPartToDelete.Id]) + } + } + else { + Write-Output "WebPart with StorageKey $StorageKey not found on page" + } + } + finally { + if ($isSiteReadOnly) { + Write-Output 'ReadOnly flag re-applied to site' + $site.ReadOnly = $true + } + $web.Dispose() + } + } + else { + Write-Output "WebID $WebID does not exist.`nPlease check this WebID" + } + $site.Dispose() + } + else { + Write-Output "SiteID $SiteID does not exist.`nPlease check this SiteID" + } + } + catch { + return $_ + } +} + function Remove-SPSMissingAssembly { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -609,7 +812,9 @@ function Remove-SPSMissingAssembly { } if ($null -ne $AssemblyToDelete) { Write-Output "Removing AssemblyID $AssemblyID from SPSite object." - $AssemblyToDelete.delete() + if ($PSCmdlet.ShouldProcess("SPSite $SiteID", "Delete AssemblyID $AssemblyID")) { + $AssemblyToDelete.delete() + } } else { Write-Output "AssemblyID $AssemblyID does not exist in SPSite object." @@ -629,7 +834,9 @@ function Remove-SPSMissingAssembly { } if ($null -ne $AssemblyToDelete) { Write-Output "Removing AssemblyID $AssemblyID from SPWeb object." - $AssemblyToDelete.delete() + if ($PSCmdlet.ShouldProcess("SPWeb $WebID", "Delete AssemblyID $AssemblyID")) { + $AssemblyToDelete.delete() + } } else { Write-Output "AssemblyID $AssemblyID does not exist in SPWeb object." @@ -651,7 +858,9 @@ function Remove-SPSMissingAssembly { } if ($null -ne $AssemblyToDelete) { Write-Output "Removing AssemblyID $AssemblyID from SPList object." - $AssemblyToDelete.delete() + if ($PSCmdlet.ShouldProcess("SPList $hostID", "Delete AssemblyID $AssemblyID")) { + $AssemblyToDelete.delete() + } } else { Write-Output "AssemblyID $AssemblyID does not exist in SPList object." @@ -674,6 +883,7 @@ function Remove-SPSMissingAssembly { } function Remove-SPSMissingConfiguration { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -708,7 +918,9 @@ function Remove-SPSMissingConfiguration { Write-Output "Removing login: $Login" Write-Output "of SiteAdministrator Property of SPWeb:" Write-Output "$($web.Url)" - $web.SiteAdministrators.Remove($Login) + if ($PSCmdlet.ShouldProcess($web.Url, "Remove SiteAdministrator $Login")) { + $web.SiteAdministrators.Remove($Login) + } } else { Write-Output "$Login does not exist in SiteAdministrators Property" @@ -731,6 +943,7 @@ function Remove-SPSMissingConfiguration { } function Remove-SPSOrphanedSite { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -752,7 +965,9 @@ function Remove-SPSOrphanedSite { $spContentDb = Get-SPContentDatabase $Database if ($null -ne $spContentDb) { Write-Output "Removing SPSite object $SiteID with the method ForceDeleteSite" - $spContentDb.ForceDeleteSite($siteID, $false, $false) + if ($PSCmdlet.ShouldProcess("SiteID $SiteID in database $Database", 'ForceDeleteSite')) { + $spContentDb.ForceDeleteSite($siteID, $false, $false) + } } else { Write-Output "SPContentDatabse $Database does not exist.`nPlease check this Database" diff --git a/scripts/SPSCleanDependencies.ps1 b/scripts/SPSCleanDependencies.ps1 index 4bc4d48..f76a690 100644 --- a/scripts/SPSCleanDependencies.ps1 +++ b/scripts/SPSCleanDependencies.ps1 @@ -22,8 +22,8 @@ .NOTES FileName: SPSCleanDependencies.ps1 Author: luigilink (Jean-Cyril DROUHIN) - Date: October 21, 2025 - Version: 1.1.0 + Date: June 11, 2026 + Version: 1.2.0 .LINK https://spjc.fr/ @@ -80,7 +80,7 @@ Exception: $_ } # Define variable -$SPSCleanDependenciesVersion = '1.1.0' +$SPSCleanDependenciesVersion = '1.2.0' $currentUser = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name $scriptRootPath = Split-Path -parent $MyInvocation.MyCommand.Definition $pathLogsFolder = Join-Path -Path $scriptRootPath -ChildPath 'Logs' -ErrorAction SilentlyContinue @@ -127,6 +127,15 @@ if ($Clean) { } if (($jsonEnvCfg.MissingWebPart).Count -ne 0) { Write-Output 'Removing Missing WebPart References' + foreach ($missingWebPart in $jsonEnvCfg.MissingWebPart) { + Remove-SPSMissingWebPart -Database $missingWebPart.Database ` + -WebPartID $missingWebPart.WebPartID ` + -StorageKey $missingWebPart.StorageKey ` + -SiteID $missingWebPart.SiteID ` + -WebID $missingWebPart.WebID ` + -DirName $missingWebPart.DirName ` + -LeafName $missingWebPart.LeafName + } } if (($jsonEnvCfg.MissingSetupFile).Count -ne 0) { Write-Output 'Removing Missing SetupFile References' @@ -148,6 +157,13 @@ if ($Clean) { -HostID $missingAssembly.HostID } } + if (($jsonEnvCfg.SiteOrphan).Count -ne 0) { + Write-Output 'Removing Orphaned Site References' + foreach ($orphanedSite in $jsonEnvCfg.SiteOrphan) { + Remove-SPSOrphanedSite -Database $orphanedSite.Database ` + -SiteID $orphanedSite.SiteID + } + } if (($jsonEnvCfg.Configuration).Count -ne 0) { Write-Output 'Checking Classic Authentication Account of each SPSite object' $defautSPSiteOwner = (Get-SPFarm).DefaultServiceAccount.Name diff --git a/tests/Modules/SPSCleanDependencies.util.Tests.ps1 b/tests/Modules/SPSCleanDependencies.util.Tests.ps1 new file mode 100644 index 0000000..c781ddf --- /dev/null +++ b/tests/Modules/SPSCleanDependencies.util.Tests.ps1 @@ -0,0 +1,199 @@ +# Pester tests for SPSCleanDependencies.util.psm1 +# Resolve repo root - works on both local and CI/CD + +BeforeAll { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $script:modulePath = Join-Path -Path $repoRoot -ChildPath 'scripts/Modules/SPSCleanDependencies.util.psm1' + + # Skip the module's import-time prelude (admin check, powercfg, SharePoint snap-in load), + # which only makes sense on a real SharePoint farm. + $script:previousSkipPrelude = $env:SPSCD_SKIP_PRELUDE + $env:SPSCD_SKIP_PRELUDE = '1' + + # Stub SharePoint cmdlets so the module can be imported on non-Windows / no-SharePoint hosts. + # Real behaviour is exercised on a SharePoint farm; these tests only validate shape & contracts. + $spsStubs = @( + 'Get-SPContentDatabase', 'Get-SPSite', 'Get-SPWeb', 'Get-SPFarm', 'Set-SPSite', + 'Test-SPContentDatabase', 'Install-SPFeature', 'Uninstall-SPFeature', + 'Disable-SPFeature', 'Invoke-Sqlcmd', 'Add-PSSnapin', 'Get-PSSnapin' + ) + foreach ($name in $spsStubs) { + if (-not (Get-Command -Name $name -ErrorAction SilentlyContinue)) { + $sb = [ScriptBlock]::Create("function global:$name { param() }") + & $sb + } + } + + # Surface real import errors instead of silently hiding them (which previously + # produced a cascade of misleading "$null or empty" failures). + Import-Module -Name $script:modulePath -Force -DisableNameChecking +} + +AfterAll { + Remove-Module -Name 'SPSCleanDependencies.util' -Force -ErrorAction SilentlyContinue + $env:SPSCD_SKIP_PRELUDE = $script:previousSkipPrelude +} + +Describe 'SPSCleanDependencies.util.psm1 Module' { + + It 'module file exists' { + $script:modulePath | Should -Exist + } + + It 'has valid PowerShell syntax' { + $parseErrors = $null + $tokens = $null + $null = [System.Management.Automation.Language.Parser]::ParseInput( + (Get-Content -Path $script:modulePath -Raw), [ref]$tokens, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty + } + + It 'module loads successfully' { + Get-Module -Name 'SPSCleanDependencies.util' | Should -Not -BeNullOrEmpty + } +} + +Describe 'SPSCleanDependencies.util.psm1 Public Functions' { + + $publicFunctions = @( + 'Get-SPSMissingServerDependencies', + 'Remove-SPSMissingFeature', + 'Remove-SPSMissingSetupFile', + 'Remove-SPSMissingAssembly', + 'Remove-SPSMissingConfiguration', + 'Remove-SPSMissingWebPart', + 'Remove-SPSOrphanedSite' + ) + + It 'exports <_>' -ForEach $publicFunctions { + Get-Command -Name $_ -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } +} + +Describe 'SPSCleanDependencies.util.psm1 SQL Helper Functions' { + + $sqlHelpers = @( + 'Get-SQLMissingSetupFileInfo', + 'Get-SQLMissingAssemblyInfo', + 'Get-SQLMissingWebPartInfo', + 'Get-SQLMissingConfiguration' + ) + + It 'defines helper <_>' -ForEach $sqlHelpers { + Get-Command -Name $_ -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } +} + +Describe 'SPSCleanDependencies.util.psm1 Function Parameter Contracts' { + + It 'Get-SPSMissingServerDependencies has a Path parameter' { + (Get-Command Get-SPSMissingServerDependencies).Parameters.Keys | Should -Contain 'Path' + } + + It 'Remove-SPSMissingFeature exposes Database, FeatureID, SiteID' { + $params = (Get-Command Remove-SPSMissingFeature).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'FeatureID' + $params | Should -Contain 'SiteID' + } + + It 'Remove-SPSMissingSetupFile exposes Database, FileID, SiteID, WebID' { + $params = (Get-Command Remove-SPSMissingSetupFile).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'FileID' + $params | Should -Contain 'SiteID' + $params | Should -Contain 'WebID' + } + + It 'Remove-SPSMissingAssembly exposes the full HostType contract' { + $params = (Get-Command Remove-SPSMissingAssembly).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'AssemblyID' + $params | Should -Contain 'SiteID' + $params | Should -Contain 'WebID' + $params | Should -Contain 'HostType' + $params | Should -Contain 'HostID' + } + + It 'Remove-SPSMissingConfiguration exposes Database, SiteID, Login' { + $params = (Get-Command Remove-SPSMissingConfiguration).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'SiteID' + $params | Should -Contain 'Login' + } + + It 'Remove-SPSMissingWebPart exposes the per-page location contract' { + $params = (Get-Command Remove-SPSMissingWebPart).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'WebPartID' + $params | Should -Contain 'StorageKey' + $params | Should -Contain 'SiteID' + $params | Should -Contain 'WebID' + $params | Should -Contain 'DirName' + $params | Should -Contain 'LeafName' + } + + It 'Remove-SPSOrphanedSite exposes Database and SiteID' { + $params = (Get-Command Remove-SPSOrphanedSite).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'SiteID' + } +} + +Describe 'Remove-SPSMissingWebPart Safety Net' { + + It 'returns early (no throw) when StorageKey is empty' { + { Remove-SPSMissingWebPart ` + -Database 'WSS_Content_Test' ` + -WebPartID '00000000-0000-0000-0000-000000000000' ` + -StorageKey '' ` + -SiteID '11111111-1111-1111-1111-111111111111' ` + -WebID '22222222-2222-2222-2222-222222222222' ` + -DirName 'SitePages' ` + -LeafName 'Home.aspx' } | Should -Not -Throw + } +} + +Describe 'SPSCleanDependencies.util.psm1 ShouldProcess Support' { + + $stateChangingFunctions = @( + 'Remove-SPSMissingFeature', + 'Remove-SPSMissingSetupFile', + 'Remove-SPSMissingWebPart', + 'Remove-SPSMissingAssembly', + 'Remove-SPSMissingConfiguration', + 'Remove-SPSOrphanedSite' + ) + + It '<_> declares SupportsShouldProcess' -ForEach $stateChangingFunctions { + $cmd = Get-Command -Name $_ -ErrorAction Stop + # CmdletBinding(SupportsShouldProcess) injects -WhatIf and -Confirm into the function. + $cmd.Parameters.Keys | Should -Contain 'WhatIf' + $cmd.Parameters.Keys | Should -Contain 'Confirm' + } +} + +Describe 'SPSCleanDependencies.util.psm1 Class Definitions' { + + BeforeAll { + $script:moduleContent = Get-Content -Path $script:modulePath -Raw + } + + $expectedClasses = @( + 'SPMissingFeaturesInfo', + 'SPMissingWebPartInfo', + 'SPMissingSetupFileInfo', + 'SPMissingAssemblyInfo', + 'SPMissingConfigurationInfo', + 'SPMissingSiteDefinition', + 'SPMissingOrphanedSites' + ) + + It 'defines class <_>' -ForEach $expectedClasses { + $script:moduleContent | Should -Match "class\s+$_\b" + } + + It 'SPMissingWebPartInfo class carries per-page location fields' { + $script:moduleContent | Should -Match 'class\s+SPMissingWebPartInfo[\s\S]*\$StorageKey[\s\S]*\$DirName[\s\S]*\$LeafName' + } +} diff --git a/tests/SPSCleanDependencies.Tests.ps1 b/tests/SPSCleanDependencies.Tests.ps1 new file mode 100644 index 0000000..f41bd22 --- /dev/null +++ b/tests/SPSCleanDependencies.Tests.ps1 @@ -0,0 +1,178 @@ +# Pester tests for SPSCleanDependencies.ps1 +# Resolve repo root - works on CI/CD (GitHub Actions) and local runs + +BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $script:scriptPath = Join-Path -Path $repoRoot -ChildPath 'scripts/SPSCleanDependencies.ps1' + $script:scriptContent = Get-Content -Path $script:scriptPath -Raw -ErrorAction SilentlyContinue +} + +Describe 'SPSCleanDependencies.ps1 File Existence' { + + It 'SPSCleanDependencies.ps1 exists' { + $script:scriptPath | Should -Exist + } + + It 'is a PowerShell script file' { + (Get-Item $script:scriptPath).Extension | Should -Be '.ps1' + } + + It 'has valid PowerShell syntax' { + $parseErrors = $null + $tokens = $null + $null = [System.Management.Automation.Language.Parser]::ParseInput( + $script:scriptContent, [ref]$tokens, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty + } +} + +Describe 'SPSCleanDependencies.ps1 Metadata' { + + It 'Should contain a SYNOPSIS' { + $script:scriptContent | Should -Match '\.SYNOPSIS' + } + + It 'Should contain a DESCRIPTION' { + $script:scriptContent | Should -Match '\.DESCRIPTION' + } + + It 'Should contain an EXAMPLE' { + $script:scriptContent | Should -Match '\.EXAMPLE' + } + + It 'Should declare an Author in NOTES' { + $script:scriptContent | Should -Match 'Author:\s*luigilink' + } + + It 'Should declare a Version in NOTES' { + $script:scriptContent | Should -Match 'Version:\s*\d+\.\d+\.\d+' + } + + It 'Should require PowerShell 5.1 or higher' { + $script:scriptContent | Should -Match '#requires\s+-Version\s+5\.1' + } +} + +Describe 'SPSCleanDependencies.ps1 Parameters' { + + BeforeAll { + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + $script:scriptContent, [ref]$null, [ref]$null) + $script:paramBlock = $ast.ParamBlock + } + + It 'Should define a param block' { + $script:paramBlock | Should -Not -BeNullOrEmpty + } + + It 'Should expose a mandatory FileName parameter' { + $fileNameParam = $script:paramBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'FileName' + } + $fileNameParam | Should -Not -BeNullOrEmpty + + $mandatoryAttr = $fileNameParam.Attributes | Where-Object { + $_ -is [System.Management.Automation.Language.AttributeAst] -and + $_.TypeName.Name -eq 'Parameter' + } + $mandatoryArg = $mandatoryAttr.NamedArguments | + Where-Object { $_.ArgumentName -eq 'Mandatory' } + $mandatoryArg | Should -Not -BeNullOrEmpty + } + + It 'Should type FileName as System.String' { + $fileNameParam = $script:paramBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'FileName' + } + $typeAttr = $fileNameParam.Attributes | Where-Object { + $_ -is [System.Management.Automation.Language.TypeConstraintAst] + } + $typeAttr.TypeName.FullName | Should -Be 'System.String' + } + + It 'Should expose a Clean switch parameter' { + $cleanParam = $script:paramBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'Clean' + } + $cleanParam | Should -Not -BeNullOrEmpty + + $typeAttr = $cleanParam.Attributes | Where-Object { + $_ -is [System.Management.Automation.Language.TypeConstraintAst] + } + $typeAttr.TypeName.FullName | Should -Be 'switch' + } + + It 'Should have exactly two parameters' { + $script:paramBlock.Parameters.Count | Should -Be 2 + } +} + +Describe 'SPSCleanDependencies.ps1 Module Imports' { + + It 'Should import the helper module SPSCleanDependencies.util.psm1' { + $script:scriptContent | Should -Match 'Import-Module[^\n]*SPSCleanDependencies\.util\.psm1' + } + + It 'Should import the SqlServer module' { + $script:scriptContent | Should -Match 'Import-Module\s+-Name\s+SqlServer' + } +} + +Describe 'SPSCleanDependencies.ps1 Logs/Results Bootstrapping' { + + It 'Should create the Logs folder if it does not exist' { + $script:scriptContent | Should -Match "New-Item[^\n]+-Name\s+'Logs'" + } + + It 'Should create the Results folder if it does not exist' { + $script:scriptContent | Should -Match "New-Item[^\n]+-Name\s+'Results'" + } + + It 'Should start a transcript' { + $script:scriptContent | Should -Match 'Start-Transcript\s+-Path\s+\$pathLogFile' + } + + It 'Should stop the transcript at the end' { + $script:scriptContent | Should -Match 'Stop-Transcript' + } +} + +Describe 'SPSCleanDependencies.ps1 Clean Branch' { + + It 'Should call Get-SPSMissingServerDependencies when -Clean is not specified' { + $script:scriptContent | Should -Match 'Get-SPSMissingServerDependencies\s+-Path\s+\$pathJsonFile' + } + + It 'Should read the JSON results file with Test-Path / ConvertFrom-Json' { + $script:scriptContent | Should -Match 'Test-Path\s+\$pathJsonFile' + $script:scriptContent | Should -Match 'ConvertFrom-Json' + } + + It 'Should call Remove-SPSMissingFeature in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingFeature\s+-Database' + } + + It 'Should call Remove-SPSMissingSetupFile in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingSetupFile\s+-Database' + } + + It 'Should call Remove-SPSMissingAssembly in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingAssembly\s+-Database' + } + + It 'Should call Remove-SPSMissingConfiguration in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingConfiguration\s+-Database' + } + + It 'Should call Remove-SPSMissingWebPart in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingWebPart\s+-Database' + } + + It 'Should call Remove-SPSOrphanedSite in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSOrphanedSite\s+-Database' + } + + It 'Should throw when the JSON results file is missing' { + $script:scriptContent | Should -Match 'Throw\s+"Missing\s+\$pathJsonFile' + } +}