From b43c2a6564ff35a79c170431b7a2c90cc4b9718e Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 11 May 2026 11:13:30 +0200 Subject: [PATCH 1/4] Update release notes and documentation for version 3.1.1: - Added `Test-SPSPendingReboot` function for reboot detection. - Introduced `Clear-ComObject` helper for COM object management. - Removed `InstallAccount` requirement from `ProductUpdate` action. - Enhanced error handling and logging in `SPSUpdate.ps1`. - Updated tests to reflect changes and ensure coverage for new functionality. --- CHANGELOG.md | 44 ++++++++++++++++ README.md | 2 +- RELEASE-NOTES.md | 63 ++++++++--------------- scripts/Modules/sps.util.psm1 | 48 +++++++++--------- scripts/Modules/util.psm1 | 66 +++++++++++++++--------- scripts/SPSUpdate.ps1 | 87 +++++++++++++------------------- scripts/SPSUpdate_README.md | 7 +-- tests/Modules/sps.util.Tests.ps1 | 14 ++--- tests/Modules/util.Tests.ps1 | 32 ++++++++++++ tests/SPSUpdate.Tests.ps1 | 10 +++- wiki/Getting-Started.md | 4 +- wiki/Usage.md | 6 +-- 12 files changed, 221 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b169a9..203cb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,50 @@ 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). +## [3.1.1] - 2026-05-11 + +### Added + +scripts/Modules/util.psm1: + +- Added `Test-SPSPendingReboot` to detect pending reboot state using multiple Windows markers (Windows Update, CBS, pending file rename, pending computer rename, and ConfigMgr). + +scripts/Modules/sps.util.psm1: + +- Added missing `Clear-ComObject` helper used by `Get-SPSLocalVersionInfo` to safely release COM objects. + +tests/Modules/util.Tests.ps1: + +- Added tests for `Test-SPSPendingReboot` and module export validation for the new helper. + +tests/Modules/sps.util.Tests.ps1: + +- Added regression test ensuring `Start-SPSProductUpdate` no longer exposes `InstallAccount`. + +tests/SPSUpdate.Tests.ps1: + +- Added assertions for consolidated reboot detection (`Test-SPSPendingReboot`) in ProductUpdate flow. +- Added regression assertion to prevent reintroduction of legacy ProductUpdate MOF cleanup code. + +### Fixed + +scripts/Modules/sps.util.psm1: + +- Fixed runtime failure in `Get-SPSLocalVersionInfo` when `Clear-ComObject` was missing. + +scripts/SPSUpdate.ps1: + +- Updated ProductUpdate to run update binaries without `InstallAccount`. +- Replaced single reboot check with consolidated reboot detection and detailed marker reporting. +- Removed legacy ProductUpdate `finally` block used for old DSC MOF cleanup. + +### Changed + +Documentation: + +- Updated ProductUpdate examples and guidance to remove `InstallAccount` requirement. +- Removed stale references to temporary MOF cleanup after ProductUpdate. + ## [3.1.0] - 2026-05-07 ### Added diff --git a/README.md b/README.md index 93f8ccb..d621eb7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Impersonation is handled using the `Invoke-Command` cmdlet in PowerShell, togeth ## ProductUpdate -The `ProductUpdate` action runs the SharePoint update binaries directly. You only need the update files accessible on the target server and a valid `InstallAccount`. +The `ProductUpdate` action runs the SharePoint update binaries directly on the local server. You only need the update files accessible on that server. ## Documentation diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 701adcb..05a7fc9 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,70 +1,47 @@ # SPSUpdate - Release Notes -## [3.1.0] - 2026-05-07 +## [3.1.1] - 2026-05-11 ### Added +scripts/Modules/util.psm1: + +- Added `Test-SPSPendingReboot` to detect pending reboot state using multiple Windows markers (Windows Update, CBS, pending file rename, pending computer rename, and ConfigMgr). + +scripts/Modules/sps.util.psm1: + +- Added missing `Clear-ComObject` helper used by `Get-SPSLocalVersionInfo` to safely release COM objects. + tests/Modules/util.Tests.ps1: -- New Pester test suite for util.psm1 module with 11 test cases -- Module loading validation tests -- Tests for Get-SPSInstalledProductVersion function (callable, return type validation) -- Tests for Add-SPSUpdateEvent function with parameter validation (Message/Source mandatory, EntryType/EventId optional) -- Tests for Invoke-SPSCommand function structure validation (Credential, Server, ScriptBlock parameters) +- Added tests for `Test-SPSPendingReboot` and module export validation for the new helper. tests/Modules/sps.util.Tests.ps1: -- Comprehensive Pester test suite for sps.util.psm1 with 4 test cases -- Get-SPSLocalVersionInfo: Tests DisplayVersion fallback when patch metadata is missing -- Start-SPSProductUpdate: Tests file blocking detection with Add-SPSUpdateEvent logging -- Start-SPSProductUpdate: Tests exit code reporting ($setupInstall.ExitCode) -- Start-SPSProductUpdate: Tests service startup type restoration with SP 2015/2019 compatibility +- Added regression test ensuring `Start-SPSProductUpdate` no longer exposes `InstallAccount`. tests/SPSUpdate.Tests.ps1: -- Main script validation test suite with expanded coverage for script structure, task handling, and event logging -- File validation: Existence, file extension matching, PowerShell syntax validation -- Content validation: param block, script version declaration, module imports (util, sps.util), Start-Transcript logging, try-catch error handling, Administrator privilege checks -- Dependency validation: Module files (util.psm1, sps.util.psm1), folders (credentialmanager, Config), sample JSON configuration files -- Configuration file validation: JSON validity, required ConfigurationName property -- Scheduled task validation: task name constants, task existence checks, TaskPath usage, and default action task management patterns -- Error logging validation: catchMessage pattern and Add-SPSUpdateEvent coverage for initialization, task scheduling, product update, credential, and configuration wizard failures +- Added assertions for consolidated reboot detection (`Test-SPSPendingReboot`) in ProductUpdate flow. +- Added regression assertion to prevent reintroduction of legacy ProductUpdate MOF cleanup code. ### Fixed -scripts/Modules/util.psm1: - -- Get-SPSInstalledProductVersion: Changed return type from string to System.Diagnostics.FileVersionInfo object to enable proper version property access (ProductMajorPart, ProductBuildPart) - scripts/Modules/sps.util.psm1: -- Get-SPSLocalVersionInfo (Lines 393-502): Fixed uninitialized $versionInfo variable by adding proper initialization in all code paths (if block, catch block, fallback assignments) -- Start-SPSProductUpdate (Lines 494-647): Fixed incorrect variable name $setup.ExitCode → $setupInstall.ExitCode for accurate error code reporting -- Start-SPSProductUpdate: Fixed version detection to use ProductMajorPart property instead of non-existent FileMajorPart on FileVersionInfo object -- Start-SPSProductUpdate (Lines 838-851): Fixed service restoration logic to use original $service.StartType from saved JSON instead of forcing all services to Disabled startup type -- Start-SPSProductUpdate (Lines 693-710): Removed misleading exception placeholder from blocked-file error message for clearer error reporting -- Start-SPSProductUpdate: Added credentialed Start-Process execution with Windows identity safety guard for cross-platform compatibility +- Fixed runtime failure in `Get-SPSLocalVersionInfo` when `Clear-ComObject` was missing. scripts/SPSUpdate.ps1: -- Added Test-ConfigurationFile to validate JSON structure and required configuration properties before execution -- Centralized scheduled task names and task folder path into script-scoped constants -- Added scheduled task existence checks before recreating full and sequence tasks -- Standardized catch blocks around key operations by composing catchMessage strings and logging failures through Add-SPSUpdateEvent -- Extended task handling in Default mode to use task variables, TaskPath, and event logging consistently +- Updated ProductUpdate to run update binaries without `InstallAccount`. +- Replaced single reboot check with consolidated reboot detection and detailed marker reporting. +- Removed legacy ProductUpdate `finally` block used for old DSC MOF cleanup. ### Changed -.github/workflows/pester.yml: - -- Updated PSScriptAnalyzer code quality step to exclude credentialmanager folder (third-party dependency) -- Filter implemented: Get-ChildItem excludes files matching '*credentialmanager*' pattern before analysis -- Improved CI/CD reliability by preventing false positives from third-party code analysis - -README.md and wiki/Getting-Started.md: +Documentation: -- Removed outdated SharePointDsc and DSC-based prerequisite/setup guidance -- Updated documentation to describe native `ProductUpdate` execution of SharePoint update binaries -- Replaced the DSC CredSSP example with direct PowerShell CredSSP configuration commands +- Updated ProductUpdate examples and guidance to remove `InstallAccount` requirement. +- Removed stale references to temporary MOF cleanup after ProductUpdate. A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/scripts/Modules/sps.util.psm1 b/scripts/Modules/sps.util.psm1 index 8189a8a..2a17e12 100644 --- a/scripts/Modules/sps.util.psm1 +++ b/scripts/Modules/sps.util.psm1 @@ -1,3 +1,26 @@ +function Clear-ComObject { + [CmdletBinding()] + param + ( + [Parameter()] + [System.Object] + $ComObject + ) + + if ($null -eq $ComObject) { + return + } + + try { + if ([System.Runtime.InteropServices.Marshal]::IsComObject($ComObject)) { + [void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($ComObject) + } + } + catch { + Write-Verbose -Message "Unable to release COM object: $($_.Exception.Message)" + } +} + function Get-SPSServersPatchStatus { [CmdletBinding()] param @@ -515,10 +538,6 @@ function Start-SPSProductUpdate { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param ( - [Parameter(Mandatory = $true)] - [System.Management.Automation.PSCredential] - $InstallAccount, - [Parameter(Mandatory = $true)] [System.String] $SetupFile, @@ -612,27 +631,8 @@ Setup file is blocked! Please use 'Unblock-File -Path $SetupFile' to unblock the } - $setupProcessParams = @{ - FilePath = $SetupFile - ArgumentList = '/quiet /passive' - Wait = $true - PassThru = $true - } - - $currentIdentity = $null - try { - $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent().Name - } - catch { - Write-Verbose -Message 'Unable to resolve current Windows identity; skipping Start-Process credential override.' - } - - if ($InstallAccount.UserName -and $currentIdentity -and $InstallAccount.UserName -ne $currentIdentity) { - Write-Verbose -Message "Starting setup using provided InstallAccount: $($InstallAccount.UserName)" - $setupProcessParams.Credential = $InstallAccount - } - $setupInstall = Start-Process @setupProcessParams + $setupInstall = Start-Process -FilePath $SetupFile -ArgumentList '/quiet /passive' -Wait -PassThru # Error codes: https://aka.ms/installerrorcodes switch ($setupInstall.ExitCode) { 0 { diff --git a/scripts/Modules/util.psm1 b/scripts/Modules/util.psm1 index 36ca248..b175de9 100644 --- a/scripts/Modules/util.psm1 +++ b/scripts/Modules/util.psm1 @@ -164,6 +164,47 @@ function Invoke-SPSCommand { } } +function Test-SPSPendingReboot { + [CmdletBinding()] + param () + + $rebootReasons = New-Object -TypeName System.Collections.Generic.List[string] + + $registryChecks = @( + @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'; Reason = 'WindowsUpdateRebootRequired' }, + @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'; Reason = 'ComponentBasedServicingRebootPending' }, + @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootInProgress'; Reason = 'ComponentBasedServicingRebootInProgress' }, + @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Services\Pending'; Reason = 'WindowsUpdateServicesPending' }, + @{ Path = 'HKLM:\SOFTWARE\Microsoft\ServerManager\CurrentRebootAttempts'; Reason = 'ServerManagerCurrentRebootAttempts' } + ) + + foreach ($check in $registryChecks) { + if (Test-Path -Path $check.Path -ErrorAction SilentlyContinue) { + $rebootReasons.Add($check.Reason) + } + } + + $sessionManager = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -ErrorAction SilentlyContinue + if ($null -ne $sessionManager -and $null -ne $sessionManager.PendingFileRenameOperations -and $sessionManager.PendingFileRenameOperations.Count -gt 0) { + $rebootReasons.Add('PendingFileRenameOperations') + } + + $activeComputerName = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -Name ComputerName -ErrorAction SilentlyContinue + $pendingComputerName = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -Name ComputerName -ErrorAction SilentlyContinue + if ($null -ne $activeComputerName -and $null -ne $pendingComputerName -and $activeComputerName.ComputerName -ne $pendingComputerName.ComputerName) { + $rebootReasons.Add('PendingComputerRename') + } + + if (Test-Path -Path 'HKLM:\SOFTWARE\Microsoft\SMS\Mobile Client\Reboot Management\RebootData' -ErrorAction SilentlyContinue) { + $rebootReasons.Add('ConfigMgrRebootPending') + } + + return [PSCustomObject]@{ + IsPending = ($rebootReasons.Count -gt 0) + Reasons = $rebootReasons.ToArray() + } +} + function Add-SPSScheduledTask { param ( @@ -331,28 +372,3 @@ function Start-SPSScheduledTask { Write-Output "Scheduled Task $Name does not exist in SharePoint Task Path" } } - -function Get-SPSRebootStatus { - [CmdletBinding()] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $Server, - - [Parameter()] - [System.Management.Automation.PSCredential] - $InstallAccount - ) - - $result = Invoke-SPSCommand -Credential $InstallAccount ` - -Arguments @($PSBoundParameters, $MyInvocation.MyCommand.Source) ` - -Server $Server ` - -ScriptBlock { - - $pending = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' - return $pending - } - - return $result -} diff --git a/scripts/SPSUpdate.ps1 b/scripts/SPSUpdate.ps1 index dee5089..8f699ff 100644 --- a/scripts/SPSUpdate.ps1 +++ b/scripts/SPSUpdate.ps1 @@ -19,27 +19,26 @@ PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action Uninstall Use the Action parameter equal to ProductUpdate if you want to run the ProductUpdate locally - PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -InstallAccount (Get-Credential) -ConfigFile 'contoso-PROD.json' + PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'contoso-PROD.json' .PARAMETER Sequence Need parameter Sequence for SPS Farm, example: PS D:\> E:\SCRIPT\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Sequence 1 .PARAMETER InstallAccount - Need parameter InstallAccount when you use the Action Install and ProductUpdate parameters + Need parameter InstallAccount when you use the Action Install parameter PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'contoso-PROD.json' - PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -InstallAccount (Get-Credential) -ConfigFile 'contoso-PROD.json' .EXAMPLE SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'contoso-PROD.json' SPSUpdate.ps1 -Action Uninstall -ConfigFile 'contoso-PROD.json' - SPSUpdate.ps1 -Action ProductUpdate -InstallAccount (Get-Credential) -ConfigFile 'contoso-PROD.json' + SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'contoso-PROD.json' .NOTES FileName: SPSUpdate.ps1 Author: Jean-Cyril DROUHIN Date: May 07, 2026 - Version: 3.1.0 + Version: 3.1.1 .LINK https://spjc.fr/ @@ -170,7 +169,7 @@ catch { } # Define variables -$SPSUpdateVersion = '3.1.0' +$SPSUpdateVersion = '3.1.1' $getDateFormatted = Get-Date -Format yyyy-MM-dd_H-mm $spsUpdateFileName = "$($Application)-$($Environment)_$($getDateFormatted)" $spsUpdateDBsFile = "$($Application)-$($Environment)-$($spFarmName)-ContentDBs.json" @@ -424,64 +423,46 @@ Exception: $_ } } 'ProductUpdate' { - # Check UserName and Password if ProductUpdate parameter is used - if (-not($PSBoundParameters.ContainsKey('InstallAccount'))) { - Write-Warning -Message ('SPSUpdate: ProductUpdate parameter is set. Please set also InstallAccount ' + ` - "parameter. `nSee https://github.com/luigilink/SPSUpdate/wiki for details.") - exit - } - else { - $UserName = $InstallAccount.UserName - $Password = $InstallAccount.GetNetworkCredential().Password - $currentDomain = 'LDAP://' + ([ADSI]'').distinguishedName - Write-Output "Checking Account `"$UserName`" ..." - $dom = New-Object System.DirectoryServices.DirectoryEntry($currentDomain, $UserName, $Password) - if ($null -eq $dom.Path) { - Write-Warning -Message "Password Invalid for user:`"$UserName`"" - exit - } - else { - # Run ProductUpdate - try { - foreach ($setupFile in $jsonEnvCfg.Binaries.SetupFileName) { - $fullSetupFilePath = Join-Path -Path $jsonEnvCfg.Binaries.SetupFullPath -ChildPath $setupFile - $spTargetServer = ([System.Net.Dns]::GetHostByName($env:COMPUTERNAME).HostName).ToString() - Write-Output @" + # Run ProductUpdate + try { + foreach ($setupFile in $jsonEnvCfg.Binaries.SetupFileName) { + $fullSetupFilePath = Join-Path -Path $jsonEnvCfg.Binaries.SetupFullPath -ChildPath $setupFile + $spTargetServer = ([System.Net.Dns]::GetHostByName($env:COMPUTERNAME).HostName).ToString() + Write-Output @" Running ProductUpdate with following parameters: SharePoint Server: $($spTargetServer) Setup File Path: $($fullSetupFilePath) Shutdown Services: $($jsonEnvCfg.Binaries.ShutdownServices) "@ - Write-Output "Getting Reboot Status on server: $spTargetServer" - $getSPSRebootStatus = Get-SPSRebootStatus -Server $spTargetServer -InstallAccount $InstallAccount - if ($getSPSRebootStatus) { - Write-Warning "A reboot is required on server: $($spTargetServer). Please reboot the server and re-run the script." - Stop-Transcript - exit - } - # Unblock setup file if it is blocked - Unblock-File -Path $fullSetupFilePath -Verbose - Start-SPSProductUpdate -InstallAccount $InstallAccount -SetupFile $fullSetupFilePath -ShutdownServices $jsonEnvCfg.Binaries.ShutdownServices + Write-Output "Getting Reboot Status on server: $spTargetServer" + $rebootStatus = Test-SPSPendingReboot + if ($rebootStatus.IsPending) { + $rebootReasons = if ($null -ne $rebootStatus.Reasons -and $rebootStatus.Reasons.Count -gt 0) { + $rebootStatus.Reasons -join ', ' } + else { + 'UnknownReason' + } + Write-Warning "A reboot is required on server: $($spTargetServer). Detected pending reboot markers: $rebootReasons. Please reboot the server and re-run the script." + Stop-Transcript + exit } - catch { - # Handle errors during Run ProductUpdate - $catchMessage = @" + # Unblock setup file if it is blocked + Unblock-File -Path $fullSetupFilePath -Verbose + Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $jsonEnvCfg.Binaries.ShutdownServices + } + } + catch { + # Handle errors during Run ProductUpdate + $catchMessage = @" Failed to run ProductUpdate on server: $($env:COMPUTERNAME) Target Server: $($spTargetServer) Exception: $_ "@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' - Stop-Transcript - exit - } - finally { - # Clean up DSC MOF File - Write-Output "Cleaning up DSC MOF File on server: $($env:COMPUTERNAME)" - Get-ChildItem -Path $PSScriptRoot -Filter '*.mof' -Recurse | Remove-Item -Force -Verbose - } - } + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' + Stop-Transcript + exit } } Default { diff --git a/scripts/SPSUpdate_README.md b/scripts/SPSUpdate_README.md index 264c3f8..fd9ca59 100644 --- a/scripts/SPSUpdate_README.md +++ b/scripts/SPSUpdate_README.md @@ -10,7 +10,6 @@ This document provides instructions for installing and configuring the **SPSUpda - Valid credentials for task scheduler setup - StoredCredential configured (if using Install) - CredSSP configured -- SharePointDsc Installed (if using ProductUpdate) ## 📁 Files Required @@ -99,16 +98,14 @@ Place `SPSUpdate.ps1`, any dependencies or modules and the configuration file in On each SharePoint Server, open PowerShell as Administrator and execute: ```powershell -E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -InstallAccount (Get-Credential) -ConfigFile 'E\SCRIPTS\Config\contoso-PROD-CONTENT.json' +E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'E\SCRIPTS\Config\contoso-PROD-CONTENT.json' ``` This will: -- Validate credentials - Getting Reboot Status on server - Unblock cumulative update files if it is blocked -- Runnung Start-SPSProductUpdate function -- Cleaning up DSC Configuration Documents on server +- Running Start-SPSProductUpdate function ## 🔄 Uninstalling diff --git a/tests/Modules/sps.util.Tests.ps1 b/tests/Modules/sps.util.Tests.ps1 index b178d98..2c3a41c 100644 --- a/tests/Modules/sps.util.Tests.ps1 +++ b/tests/Modules/sps.util.Tests.ps1 @@ -44,10 +44,12 @@ Describe 'Get-SPSLocalVersionInfo' { } Describe 'Start-SPSProductUpdate' { - BeforeEach { - $securePassword = ConvertTo-SecureString -String 'P@ssw0rd!' -AsPlainText -Force - $script:testCredential = New-Object System.Management.Automation.PSCredential ('CONTOSO\\spinstall', $securePassword) + It 'does not expose InstallAccount parameter anymore' { + $cmd = Get-Command -Name Start-SPSProductUpdate -Module sps.util + $cmd.Parameters.Keys | Should -Not -Contain 'InstallAccount' + } + BeforeEach { Mock -ModuleName sps.util -CommandName Test-Path -MockWith { $true } Mock -ModuleName sps.util -CommandName Get-ItemProperty -ParameterFilter { $Path -eq 'C:\\setup.exe' } -MockWith { [pscustomobject]@{ VersionInfo = [pscustomobject]@{ FileVersion = '16.0.20000.10000' } } @@ -63,7 +65,7 @@ Describe 'Start-SPSProductUpdate' { [pscustomobject]@{ Stream = 'Zone.Identifier' } } - { Start-SPSProductUpdate -InstallAccount $script:testCredential -SetupFile 'C:\\setup.exe' -ShutdownServices $false } | + { Start-SPSProductUpdate -SetupFile 'C:\\setup.exe' -ShutdownServices $false } | Should -Throw '*Setup file is blocked*' Assert-MockCalled -ModuleName sps.util -CommandName Add-SPSUpdateEvent -Times 1 -Exactly @@ -76,7 +78,7 @@ Describe 'Start-SPSProductUpdate' { } { - Start-SPSProductUpdate -InstallAccount $script:testCredential -SetupFile 'C:\\setup.exe' -ShutdownServices $false + Start-SPSProductUpdate -SetupFile 'C:\\setup.exe' -ShutdownServices $false } | Should -Throw '*exit code was 1234*' } @@ -99,7 +101,7 @@ Describe 'Start-SPSProductUpdate' { [pscustomobject]@{ ExitCode = 0 } } - Start-SPSProductUpdate -InstallAccount $script:testCredential -SetupFile 'C:\\setup.exe' -ShutdownServices $true + Start-SPSProductUpdate -SetupFile 'C:\\setup.exe' -ShutdownServices $true Assert-MockCalled -ModuleName sps.util -CommandName Get-Service -Times 1 -ParameterFilter { $Name -contains 'OSearch15' } Assert-MockCalled -ModuleName sps.util -CommandName Set-Service -Times 1 -ParameterFilter { diff --git a/tests/Modules/util.Tests.ps1 b/tests/Modules/util.Tests.ps1 index ebe12a2..8a83205 100644 --- a/tests/Modules/util.Tests.ps1 +++ b/tests/Modules/util.Tests.ps1 @@ -20,6 +20,10 @@ Describe 'util.psm1 Module' { It 'exports Invoke-SPSCommand' { Get-Command -Name Invoke-SPSCommand -Module util | Should -Not -BeNullOrEmpty } + + It 'exports Test-SPSPendingReboot' { + Get-Command -Name Test-SPSPendingReboot -Module util | Should -Not -BeNullOrEmpty + } } Describe 'Get-SPSInstalledProductVersion' { @@ -83,3 +87,31 @@ Describe 'Invoke-SPSCommand' { $cmd.Parameters.Keys | Should -Contain 'ScriptBlock' } } + +Describe 'Test-SPSPendingReboot' { + It 'returns IsPending false when no reboot markers exist' { + Mock -ModuleName util -CommandName Test-Path -MockWith { $false } + Mock -ModuleName util -CommandName Get-ItemProperty -MockWith { $null } + + $result = Test-SPSPendingReboot + + $result.IsPending | Should -Be $false + $result.Reasons.Count | Should -Be 0 + } + + It 'returns IsPending true when reboot-required registry key exists' { + Mock -ModuleName util -CommandName Test-Path -MockWith { + param($Path) + if ($Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { + return $true + } + return $false + } + Mock -ModuleName util -CommandName Get-ItemProperty -MockWith { $null } + + $result = Test-SPSPendingReboot + + $result.IsPending | Should -Be $true + $result.Reasons | Should -Contain 'WindowsUpdateRebootRequired' + } +} diff --git a/tests/SPSUpdate.Tests.ps1 b/tests/SPSUpdate.Tests.ps1 index d98279b..d47c63f 100644 --- a/tests/SPSUpdate.Tests.ps1 +++ b/tests/SPSUpdate.Tests.ps1 @@ -173,7 +173,7 @@ Describe 'SPSUpdate.ps1 Content Validation' { } It 'defines the current script version' { - $scriptContent | Should -Match '\$SPSUpdateVersion\s*=\s*''3\.1\.0''' + $scriptContent | Should -Match '\$SPSUpdateVersion\s*=\s*''3\.1\.1''' } It 'imports util module' { @@ -195,6 +195,14 @@ Describe 'SPSUpdate.ps1 Content Validation' { It 'checks for administrator privileges' { $scriptContent | Should -Match 'Administrator' } + + It 'uses consolidated reboot detection for ProductUpdate' { + $scriptContent | Should -Match 'Test-SPSPendingReboot' + } + + It 'does not contain legacy ProductUpdate MOF cleanup code' { + $scriptContent | Should -Not -Match 'Cleaning up DSC MOF File|\.mof' + } } Describe 'SPSUpdate.ps1 Dependencies' { diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index adaf4f0..341e857 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -38,7 +38,7 @@ In the above example, the delegate computer value can be a wildcard name such as 1. Install Cumulative Update binaries on each server by running the following command (or install manually): ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD-CONTENT.json' -Action ProductUpdate -InstallAccount (Get-Credential) +.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD-CONTENT.json' -Action ProductUpdate ``` `ProductUpdate` runs the SharePoint installer directly and does not require any DSC module. @@ -47,6 +47,8 @@ In the above example, the delegate computer value can be a wildcard name such as > Configure the StoredCredential parameter in JSON before running the script in installation mode. > Run the Install mode with the same account than you used the in InstallAccount parameter +`ProductUpdate` runs locally and does not require the `InstallAccount` parameter. + ## Next Step For the next steps, go to the [Configuration](./Configuration) page. diff --git a/wiki/Usage.md b/wiki/Usage.md index 7ac924c..c35a134 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -18,7 +18,7 @@ | `ConfigFile` | Specifies the path to the configuration file. | | `Sequence` | (Optional) Specifies the Sequence for parallel upgrade Content DB. | | `Action` | (Optional) Use the Action parameter equal to Install to add the script in taskscheduler, InstallAccount parameter need to be set. Use the Action parameter equal to Uninstall to remove the script from taskscheduler. | -| `InstallAccount` | (Optional) Need parameter InstallAccount whent you use the Action parameter equal to Install or ProductUpdate. | +| `InstallAccount` | (Optional) Need parameter InstallAccount when you use the Action parameter equal to Install. | ## Examples @@ -49,7 +49,7 @@ ### Example 5: ProductUpdate Usage Example ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Action ProductUpdate -InstallAccount (Get-Credential) +.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Action ProductUpdate ``` ## Logging @@ -58,7 +58,7 @@ The script logs the status of each task, including success or failure, and saves ## Error Handling -- Ensure the provided credentials have access to the SharePoint Farm. +- Ensure the account running the script has administrator rights and access to the SharePoint Farm. ## Notes From da995346b477f4d9d7a2acb0edf457c9851a79f4 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 11 May 2026 11:27:11 +0200 Subject: [PATCH 2/4] Update Start-SPSProductUpdate command to use -ErrorAction Stop for improved error handling --- tests/Modules/sps.util.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Modules/sps.util.Tests.ps1 b/tests/Modules/sps.util.Tests.ps1 index 2c3a41c..61d71e0 100644 --- a/tests/Modules/sps.util.Tests.ps1 +++ b/tests/Modules/sps.util.Tests.ps1 @@ -45,7 +45,7 @@ Describe 'Get-SPSLocalVersionInfo' { Describe 'Start-SPSProductUpdate' { It 'does not expose InstallAccount parameter anymore' { - $cmd = Get-Command -Name Start-SPSProductUpdate -Module sps.util + $cmd = Get-Command -Name Start-SPSProductUpdate -ErrorAction Stop $cmd.Parameters.Keys | Should -Not -Contain 'InstallAccount' } From 30ef2f7ba76432cc9eec76c55e20dcb1a39a2ec0 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 11 May 2026 17:45:53 +0200 Subject: [PATCH 3/4] Enhance Start-SPSScheduledTask with TaskPath support and normalization; add regression tests for task management and improve error handling in SPSUpdate script --- CHANGELOG.md | 10 +++ RELEASE-NOTES.md | 10 +++ scripts/Modules/util.psm1 | 125 ++++++++++++++++++++--------------- scripts/SPSUpdate.ps1 | 71 +++++++++++++++----- tests/Modules/util.Tests.ps1 | 72 ++++++++++++++++++++ tests/SPSUpdate.Tests.ps1 | 21 +++++- 6 files changed, 238 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 203cb31..733e1dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ scripts/Modules/sps.util.psm1: tests/Modules/util.Tests.ps1: - Added tests for `Test-SPSPendingReboot` and module export validation for the new helper. +- Added regression tests for `Start-SPSScheduledTask` `TaskPath` parameter support and task path normalization behavior. tests/Modules/sps.util.Tests.ps1: @@ -30,6 +31,12 @@ tests/SPSUpdate.Tests.ps1: ### Fixed +scripts/Modules/util.psm1: + +- Fixed `Start-SPSScheduledTask` to support `TaskPath` and use that path when resolving and starting tasks. +- Normalized scheduled task paths to the expected `\` format for `Get-ScheduledTask` and `Start-ScheduledTask`. +- Changed `Add-SPSScheduledTask` behavior to create or update existing tasks instead of skipping them. + scripts/Modules/sps.util.psm1: - Fixed runtime failure in `Get-SPSLocalVersionInfo` when `Clear-ComObject` was missing. @@ -39,6 +46,9 @@ scripts/SPSUpdate.ps1: - Updated ProductUpdate to run update binaries without `InstallAccount`. - Replaced single reboot check with consolidated reboot detection and detailed marker reporting. - Removed legacy ProductUpdate `finally` block used for old DSC MOF cleanup. +- Enabled script-level verbose forwarding (`-Verbose`) to downstream module cmdlets. +- Added transcript file path output (`Transcript log file: ...`) at startup for easier log discovery. +- Updated installed SharePoint version output to print `FileVersion` explicitly. ### Changed diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 05a7fc9..5c8063d 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -15,6 +15,7 @@ scripts/Modules/sps.util.psm1: tests/Modules/util.Tests.ps1: - Added tests for `Test-SPSPendingReboot` and module export validation for the new helper. +- Added regression tests for `Start-SPSScheduledTask` `TaskPath` parameter support and task path normalization behavior. tests/Modules/sps.util.Tests.ps1: @@ -27,6 +28,12 @@ tests/SPSUpdate.Tests.ps1: ### Fixed +scripts/Modules/util.psm1: + +- Fixed `Start-SPSScheduledTask` to support `TaskPath` and use that path when resolving and starting tasks. +- Normalized scheduled task paths to the expected `\` format for `Get-ScheduledTask` and `Start-ScheduledTask`. +- Changed `Add-SPSScheduledTask` behavior to create or update existing tasks instead of skipping them. + scripts/Modules/sps.util.psm1: - Fixed runtime failure in `Get-SPSLocalVersionInfo` when `Clear-ComObject` was missing. @@ -36,6 +43,9 @@ scripts/SPSUpdate.ps1: - Updated ProductUpdate to run update binaries without `InstallAccount`. - Replaced single reboot check with consolidated reboot detection and detailed marker reporting. - Removed legacy ProductUpdate `finally` block used for old DSC MOF cleanup. +- Enabled script-level verbose forwarding (`-Verbose`) to downstream module cmdlets. +- Added transcript file path output (`Transcript log file: ...`) at startup for easier log discovery. +- Updated installed SharePoint version output to print `FileVersion` explicitly. ### Changed diff --git a/scripts/Modules/util.psm1 b/scripts/Modules/util.psm1 index b175de9..cf683ff 100644 --- a/scripts/Modules/util.psm1 +++ b/scripts/Modules/util.psm1 @@ -174,7 +174,6 @@ function Test-SPSPendingReboot { @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'; Reason = 'WindowsUpdateRebootRequired' }, @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'; Reason = 'ComponentBasedServicingRebootPending' }, @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootInProgress'; Reason = 'ComponentBasedServicingRebootInProgress' }, - @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Services\Pending'; Reason = 'WindowsUpdateServicesPending' }, @{ Path = 'HKLM:\SOFTWARE\Microsoft\ServerManager\CurrentRebootAttempts'; Reason = 'ServerManagerCurrentRebootAttempts' } ) @@ -184,6 +183,15 @@ function Test-SPSPendingReboot { } } + # WindowsUpdate\Services\Pending can exist even after reboot; require at least one child entry. + $wuServicesPendingPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Services\Pending' + if (Test-Path -Path $wuServicesPendingPath -ErrorAction SilentlyContinue) { + $wuPendingEntries = Get-ChildItem -Path $wuServicesPendingPath -ErrorAction SilentlyContinue + if ($null -ne $wuPendingEntries -and $wuPendingEntries.Count -gt 0) { + $rebootReasons.Add('WindowsUpdateServicesPending') + } + } + $sessionManager = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -ErrorAction SilentlyContinue if ($null -ne $sessionManager -and $null -ne $sessionManager.PendingFileRenameOperations -and $sessionManager.PendingFileRenameOperations.Count -gt 0) { $rebootReasons.Add('PendingFileRenameOperations') @@ -250,54 +258,44 @@ function Add-SPSScheduledTask { Write-Output "Successfully created task folder '$TaskPath'" } - # Retrieve the scheduled task - $getScheduledTask = $TaskFolder.GetTasks(0) | Where-Object -FilterScript { - $_.Name -eq $Name - } + Write-Output '--------------------------------------------------------------' + Write-Output "Adding or updating '$Name' script in Task Scheduler Service ..." - if ($getScheduledTask) { - Write-Warning -Message 'Scheduled Task already exists - skipping.' # Task already exists - } - else { - Write-Output '--------------------------------------------------------------' - Write-Output "Adding '$Name' script in Task Scheduler Service ..." - - # Get credentials for Task Schedule - $TaskAuthor = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name # Author of the task - $TaskUser = $UserName # Username for task registration - $TaskUserPwd = $Password # Password for task registration - - # Add a new Task Schedule - $TaskSchd = $TaskSvc.NewTask(0) - $TaskSchd.RegistrationInfo.Description = "$($Description)" # Task description - $TaskSchd.RegistrationInfo.Author = $TaskAuthor # Task author - $TaskSchd.Principal.RunLevel = 1 # Task run level (1 = Highest) - - # Task Schedule - Modify Settings Section - $TaskSettings = $TaskSchd.Settings - $TaskSettings.AllowDemandStart = $true - $TaskSettings.Enabled = $true - $TaskSettings.Hidden = $false - $TaskSettings.StartWhenAvailable = $true - - # Define the task action - $TaskAction = $TaskSchd.Actions.Create(0) # 0 = Executable action - $TaskAction.Path = $TaskCmd # Path to the executable - $TaskAction.Arguments = $ActionArguments # Arguments for the executable + # Get credentials for Task Schedule + $TaskAuthor = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name # Author of the task + $TaskUser = $UserName # Username for task registration + $TaskUserPwd = $Password # Password for task registration - try { - # Register the task - $TaskFolder.RegisterTaskDefinition($Name, $TaskSchd, 6, $TaskUser, $TaskUserPwd, 1) - Write-Output "Successfully added '$Name' script in Task Scheduler Service" - } - catch { - $catchMessage = @" -An error occurred while adding the script in scheduled task: $($Name) + # Add a new Task Schedule + $TaskSchd = $TaskSvc.NewTask(0) + $TaskSchd.RegistrationInfo.Description = "$($Description)" # Task description + $TaskSchd.RegistrationInfo.Author = $TaskAuthor # Task author + $TaskSchd.Principal.RunLevel = 1 # Task run level (1 = Highest) + + # Task Schedule - Modify Settings Section + $TaskSettings = $TaskSchd.Settings + $TaskSettings.AllowDemandStart = $true + $TaskSettings.Enabled = $true + $TaskSettings.Hidden = $false + $TaskSettings.StartWhenAvailable = $true + + # Define the task action + $TaskAction = $TaskSchd.Actions.Create(0) # 0 = Executable action + $TaskAction.Path = $TaskCmd # Path to the executable + $TaskAction.Arguments = $ActionArguments # Arguments for the executable + + try { + # Register/update the task (6 = create or update) + $TaskFolder.RegisterTaskDefinition($Name, $TaskSchd, 6, $TaskUser, $TaskUserPwd, 1) + Write-Output "Successfully added or updated '$Name' script in Task Scheduler Service" + } + catch { + $catchMessage = @" +An error occurred while adding/updating the script in scheduled task: $($Name) ActionArguments: $($ActionArguments) Exception: $($_.Exception.Message) "@ - Write-Error -Message $catchMessage # Handle any errors during task registration - } + Write-Error -Message $catchMessage # Handle any errors during task registration } } @@ -358,17 +356,38 @@ function Start-SPSScheduledTask { ( [Parameter(Mandatory = $true)] [System.String] - $Name + $Name, + + [Parameter()] + [System.String] + $TaskPath = 'SharePoint' ) - $getScheduledTask = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue - if ($getScheduledTask) { - if ($PSCmdlet.ShouldProcess($Name, 'Start scheduled task')) { - Start-ScheduledTask -TaskName $Name ` - -TaskPath 'SharePoint' ` - -ErrorAction SilentlyContinue - } + + $normalizedTaskPath = if ($TaskPath.StartsWith('\')) { + $TaskPath } else { - Write-Output "Scheduled Task $Name does not exist in SharePoint Task Path" + "\$TaskPath" + } + if (-not $normalizedTaskPath.EndsWith('\')) { + $normalizedTaskPath = "$normalizedTaskPath\" + } + + $getScheduledTask = Get-ScheduledTask -TaskName $Name -TaskPath $normalizedTaskPath -ErrorAction SilentlyContinue + if (-not $getScheduledTask) { + throw "Scheduled Task $Name does not exist in $TaskPath Task Path" + } + + if ($PSCmdlet.ShouldProcess($Name, 'Start scheduled task')) { + Start-ScheduledTask -TaskName $Name ` + -TaskPath $normalizedTaskPath ` + -ErrorAction Stop + } + + $startedTask = Get-ScheduledTask -TaskName $Name -TaskPath $normalizedTaskPath -ErrorAction Stop + return [PSCustomObject]@{ + Name = $Name + TaskPath = $normalizedTaskPath + State = $startedTask.State } } diff --git a/scripts/SPSUpdate.ps1 b/scripts/SPSUpdate.ps1 index 8f699ff..cd4f340 100644 --- a/scripts/SPSUpdate.ps1 +++ b/scripts/SPSUpdate.ps1 @@ -37,13 +37,14 @@ .NOTES FileName: SPSUpdate.ps1 Author: Jean-Cyril DROUHIN - Date: May 07, 2026 + Date: May 11, 2026 Version: 3.1.1 .LINK https://spjc.fr/ https://github.com/luigilink/SPSUpdate #> +[CmdletBinding()] param ( [Parameter(Position = 0, Mandatory = $true)] @@ -67,6 +68,12 @@ param ) #region Initialization +# When the script is invoked with -Verbose, forward that preference to all downstream commands +# that support the common Verbose parameter, including imported module functions. +if ($PSBoundParameters.ContainsKey('Verbose')) { + $PSDefaultParameterValues['*:Verbose'] = $true +} + # Clear the host console Clear-Host @@ -194,9 +201,18 @@ else { } $DateStarted = Get-Date $psVersion = $PSVersionTable.PSVersion.ToString() +$script:TranscriptStarted = $false # Start transcript to log the output -Start-Transcript -Path $pathLogFile -IncludeInvocationHeader +try { + Start-Transcript -Path $pathLogFile -IncludeInvocationHeader -ErrorAction Stop + $script:TranscriptStarted = $true + Write-Output "Transcript log file: $pathLogFile" +} +catch { + Write-Warning "Unable to start transcript: $($_.Exception.Message)" + Write-Output "Transcript disabled for this run. Intended log file path: $pathLogFile" +} # Output the script information Write-Output '-----------------------------------------------' @@ -215,7 +231,7 @@ Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/ # 1. Load SharePoint Powershell Snapin or Import-Module try { $installedVersion = Get-SPSInstalledProductVersion - Write-Output "Installed SharePoint Product Version: $($installedVersion)" + Write-Output "Installed SharePoint Product Version: $($installedVersion.FileVersion)" 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 @@ -356,7 +372,7 @@ Exception: $_ Write-Output "Adding Scheduled Task $script:TaskNameFullScript in $script:TaskPath Task Path" # Check if task already exists - $existingTask = Get-ScheduledTask -TaskName $script:TaskNameFullScript -TaskPath "\$script:TaskPath" -ErrorAction SilentlyContinue + $existingTask = Get-ScheduledTask -TaskName $script:TaskNameFullScript -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue if ($null -ne $existingTask) { Write-Warning "Scheduled task '$script:TaskNameFullScript' already exists. Removing and recreating..." Remove-SPSScheduledTask -Name $script:TaskNameFullScript -TaskPath $script:TaskPath @@ -393,7 +409,7 @@ Exception: $_ Write-Output "Adding Scheduled Task $taskName in $script:TaskPath Task Path" # Check if task already exists - $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath "\$script:TaskPath" -ErrorAction SilentlyContinue + $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue if ($null -ne $existingTask) { Write-Warning "Scheduled task '$taskName' already exists. Removing and recreating..." Remove-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath @@ -415,7 +431,10 @@ Exception: $_ "@ Write-Error -Message $catchMessage # Handle any errors during task removal Add-SPSUpdateEvent -Message $catchMessage -Source 'Add-SPSScheduledTask' -EntryType 'Error' - Stop-Transcript + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } exit } } @@ -444,12 +463,15 @@ Shutdown Services: $($jsonEnvCfg.Binaries.ShutdownServices) 'UnknownReason' } Write-Warning "A reboot is required on server: $($spTargetServer). Detected pending reboot markers: $rebootReasons. Please reboot the server and re-run the script." - Stop-Transcript + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } exit } # Unblock setup file if it is blocked Unblock-File -Path $fullSetupFilePath -Verbose - Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $jsonEnvCfg.Binaries.ShutdownServices + Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $jsonEnvCfg.Binaries.ShutdownServices -Verbose } } catch { @@ -461,7 +483,10 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' - Stop-Transcript + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } exit } } @@ -505,7 +530,10 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Get-StoredCredential' -EntryType 'Error' - Stop-Transcript + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } exit } Write-Output "Update Script in progress | FULL Mode - Please Wait ..." @@ -520,7 +548,7 @@ Exception: $_ Write-Output "Adding Scheduled Task $taskName in $script:TaskPath Task Path" # Check if task already exists - $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath "\$script:TaskPath" -ErrorAction SilentlyContinue + $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue if ($null -ne $existingTask) { Write-Warning "Scheduled task '$taskName' already exists. Removing and recreating..." Remove-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath @@ -542,7 +570,10 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Add-SPSScheduledTask' -EntryType 'Error' - Stop-Transcript + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } exit } } @@ -552,7 +583,8 @@ Exception: $_ try { $taskName = "$script:TaskNameSequencePrefix$taskId" Write-Output "Running Scheduled Task $taskName in $script:TaskPath Task Path" - Start-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath + $startResult = Start-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath -ErrorAction Stop + Write-Output "Start requested for $($startResult.Name) in $($startResult.TaskPath). Current state: $($startResult.State)" Write-Output 'Avoid conflicts with OWSTimer process - Pause between 60 to 90 seconds' Start-Sleep -Seconds (get-random (60..90)) } @@ -583,8 +615,12 @@ Exception: $_ while (-not $allTasksFinished) { $allTasksFinished = $true foreach ($scheduledTask in $scheduledTasks) { - $taskStatus = Get-ScheduledTask -TaskName $scheduledTask | Select-Object State - if ($taskStatus.State -ne 'Running') { + $taskStatus = Get-ScheduledTask -TaskName $scheduledTask -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue + if ($null -eq $taskStatus) { + Write-Warning "Scheduled Task $scheduledTask was not found in $script:TaskPath Task Path" + continue + } + if ($taskStatus.State -ne 'Running' -and $taskStatus.State -ne 'Queued') { Write-Output "Scheduled Task $($scheduledTask) has finished or is not running" } else { @@ -702,7 +738,10 @@ Write-Output "| SPSUpdate Script Completed" Write-Output "| Started on - $DateStarted" Write-Output "| Ended on - $DateEnded" Write-Output '-----------------------------------------------' -Stop-Transcript +if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false +} $loadedModules = @('util', 'CredentialManager') $loadedModules | ForEach-Object { Remove-Module -Name $_ -ErrorAction SilentlyContinue } $error.Clear() diff --git a/tests/Modules/util.Tests.ps1 b/tests/Modules/util.Tests.ps1 index 8a83205..ae08347 100644 --- a/tests/Modules/util.Tests.ps1 +++ b/tests/Modules/util.Tests.ps1 @@ -88,6 +88,42 @@ Describe 'Invoke-SPSCommand' { } } +Describe 'Start-SPSScheduledTask' { + It 'has TaskPath parameter' { + $cmd = Get-Command -Name Start-SPSScheduledTask -Module util + $cmd.Parameters.Keys | Should -Contain 'TaskPath' + } + + It 'normalizes TaskPath and starts task when task exists' { + Mock -ModuleName util -CommandName Get-ScheduledTask -MockWith { + [pscustomobject]@{ TaskName = 'SPSUpdate-Sequence1'; State = 'Ready' } + } + Mock -ModuleName util -CommandName Start-ScheduledTask -MockWith { } + + $result = Start-SPSScheduledTask -Name 'SPSUpdate-Sequence1' -TaskPath 'SharePoint' -Confirm:$false + + $result.Name | Should -Be 'SPSUpdate-Sequence1' + $result.TaskPath | Should -Be '\SharePoint\' + $result.State | Should -Be 'Ready' + + Assert-MockCalled -ModuleName util -CommandName Get-ScheduledTask -Times 2 -Exactly -ParameterFilter { + $TaskName -eq 'SPSUpdate-Sequence1' -and $TaskPath -eq '\SharePoint\' + } + Assert-MockCalled -ModuleName util -CommandName Start-ScheduledTask -Times 1 -Exactly -ParameterFilter { + $TaskName -eq 'SPSUpdate-Sequence1' -and $TaskPath -eq '\SharePoint\' + } + } + + It 'throws a clear message when task does not exist in given TaskPath' { + Mock -ModuleName util -CommandName Get-ScheduledTask -MockWith { $null } + Mock -ModuleName util -CommandName Start-ScheduledTask -MockWith { } + + { Start-SPSScheduledTask -Name 'MissingTask' -TaskPath 'SharePoint' -Confirm:$false } | Should -Throw 'Scheduled Task MissingTask does not exist in SharePoint Task Path' + + Assert-MockCalled -ModuleName util -CommandName Start-ScheduledTask -Times 0 + } +} + Describe 'Test-SPSPendingReboot' { It 'returns IsPending false when no reboot markers exist' { Mock -ModuleName util -CommandName Test-Path -MockWith { $false } @@ -114,4 +150,40 @@ Describe 'Test-SPSPendingReboot' { $result.IsPending | Should -Be $true $result.Reasons | Should -Contain 'WindowsUpdateRebootRequired' } + + It 'does not flag WindowsUpdateServicesPending when Pending key has no child entries' { + Mock -ModuleName util -CommandName Test-Path -MockWith { + param($Path) + if ($Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Services\Pending') { + return $true + } + return $false + } + Mock -ModuleName util -CommandName Get-ChildItem -MockWith { @() } + Mock -ModuleName util -CommandName Get-ItemProperty -MockWith { $null } + + $result = Test-SPSPendingReboot + + $result.IsPending | Should -Be $false + $result.Reasons | Should -Not -Contain 'WindowsUpdateServicesPending' + } + + It 'flags WindowsUpdateServicesPending when Pending key has child entries' { + Mock -ModuleName util -CommandName Test-Path -MockWith { + param($Path) + if ($Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Services\Pending') { + return $true + } + return $false + } + Mock -ModuleName util -CommandName Get-ChildItem -MockWith { + @([pscustomobject]@{ Name = 'Service1' }) + } + Mock -ModuleName util -CommandName Get-ItemProperty -MockWith { $null } + + $result = Test-SPSPendingReboot + + $result.IsPending | Should -Be $true + $result.Reasons | Should -Contain 'WindowsUpdateServicesPending' + } } diff --git a/tests/SPSUpdate.Tests.ps1 b/tests/SPSUpdate.Tests.ps1 index d47c63f..b54f147 100644 --- a/tests/SPSUpdate.Tests.ps1 +++ b/tests/SPSUpdate.Tests.ps1 @@ -94,11 +94,11 @@ Describe 'SPSUpdate.ps1 Scheduled Task Existence Check' { } It 'uses existence check in Install action' { - $scriptContent | Should -Match '(?s)''Install''\s*\{.*Get-ScheduledTask -TaskName \$script:TaskNameFullScript -TaskPath "\\\$script:TaskPath"' + $scriptContent | Should -Match '(?s)''Install''\s*\{.*Get-ScheduledTask -TaskName \$script:TaskNameFullScript -TaskPath "\\\$script:TaskPath\\"' } It 'uses existence check in Default action' { - $scriptContent | Should -Match '(?s)Default\s*\{.*Get-ScheduledTask -TaskName \$taskName -TaskPath "\\\$script:TaskPath"' + $scriptContent | Should -Match '(?s)Default\s*\{.*Get-ScheduledTask -TaskName \$taskName -TaskPath "\\\$script:TaskPath\\"' } } @@ -116,6 +116,18 @@ Describe 'SPSUpdate.ps1 Default Action Task Management' { $scriptContent | Should -Match 'Start-SPSScheduledTask.*-TaskPath\s*\$script:TaskPath' } + It 'Default action captures start result and enforces ErrorAction Stop' { + $scriptContent | Should -Match '\$startResult\s*=\s*Start-SPSScheduledTask\s+-Name\s+\$taskName\s+-TaskPath\s+\$script:TaskPath\s+-ErrorAction\s+Stop' + } + + It 'Default action queries scheduled task state with TaskPath' { + $scriptContent | Should -Match 'Get-ScheduledTask\s+-TaskName\s+\$scheduledTask\s+-TaskPath\s+"\\\$script:TaskPath\\"' + } + + It 'Default action treats Queued tasks as still active' { + $scriptContent | Should -Match "\$taskStatus\.State\s+-ne\s+'Running'\s+-and\s+\$taskStatus\.State\s+-ne\s+'Queued'" + } + It 'Default action passes -TaskPath to Add-SPSScheduledTask' { $scriptContent | Should -Match '(?s)Default\s*\{.*Add-SPSScheduledTask -Name \$taskName.*-TaskPath \$script:TaskPath' -Because 'Default action should use TaskPath constant when adding sequence tasks' } @@ -188,6 +200,11 @@ Describe 'SPSUpdate.ps1 Content Validation' { $scriptContent | Should -Match 'Start-Transcript' } + It 'guards Stop-Transcript behind TranscriptStarted state' { + $scriptContent | Should -Match '\$script:TranscriptStarted\s*=\s*\$false' + $scriptContent | Should -Match 'if\s*\(\$script:TranscriptStarted\)\s*\{\s*Stop-Transcript' + } + It 'contains try-catch error handling' { $scriptContent | Should -Match 'try\s*\{|catch\s*\{' } From 338f127519cf78e251b349cc47c636d79ad03822 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 11 May 2026 17:50:19 +0200 Subject: [PATCH 4/4] Fix string escaping in task status check for Default action in SPSUpdate.Tests.ps1 --- tests/SPSUpdate.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SPSUpdate.Tests.ps1 b/tests/SPSUpdate.Tests.ps1 index b54f147..055fa7b 100644 --- a/tests/SPSUpdate.Tests.ps1 +++ b/tests/SPSUpdate.Tests.ps1 @@ -125,7 +125,7 @@ Describe 'SPSUpdate.ps1 Default Action Task Management' { } It 'Default action treats Queued tasks as still active' { - $scriptContent | Should -Match "\$taskStatus\.State\s+-ne\s+'Running'\s+-and\s+\$taskStatus\.State\s+-ne\s+'Queued'" + $scriptContent | Should -Match '\$taskStatus\.State\s+-ne\s+''Running''\s+-and\s+\$taskStatus\.State\s+-ne\s+''Queued''' } It 'Default action passes -TaskPath to Add-SPSScheduledTask' {