diff --git a/README.md b/README.md index 730a3b5..9657a24 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ the manifest key to edit — when something needs one. [x] Free space OK 412 GB free of 476 GB [x] TPM readiness OK TPM present and ready. [x] Drive encryption OK Protected: C: FullyEncrypted. + [x] Windows Update OK No failed update in the last 50 entries + (30 days). | Restart pending: none. [x] Network adapters OK [x] Outlook OK [!] VPN client WARNING No matching process running (PanGPA, PanGPS). @@ -271,6 +273,15 @@ after enrolment will see the warning on a fresh machine, which is the correct thing for a handover checklist to say. The tool only ever reports — it never enables BitLocker and never touches a recovery key. +*Windows Update* is read twice and never searched: the recent update history +(the last 50 entries, and only the last 30 days) and the `RebootRequired` key. +An update that tried and failed is a warning naming the update and its HRESULT; +a restart Windows is waiting for is a warning too, folded into the single +`Restart required` row so one restart covers it and a rename both. Neither reads +the network. "Are updates pending" is deliberately not checked — that search goes +to Microsoft, takes minutes, can hang, and a freshly imaged machine always has +some, so it would warn on every run. + `Domain.Name` is the domain the machine-identity prompt offers to join, and `Domain.OUPath` the optional organisational unit the machine account is created in. Leave `Name` empty and the domain option disappears from the prompt entirely, @@ -354,7 +365,7 @@ tests/ ## Documentation -- **[The manual (PDF)](docs/manual.pdf)** — 27 pages: what the tool does, what +- **[The manual (PDF)](docs/manual.pdf)** — 28 pages: what the tool does, what it leaves to you, the architecture in diagrams, PowerShell explained from nothing, how to add your own module, and how to read the diagnostic when something goes wrong. diff --git a/docs/manual.pdf b/docs/manual.pdf index b524380..88a7d44 100644 Binary files a/docs/manual.pdf and b/docs/manual.pdf differ diff --git a/docs/manual.typ b/docs/manual.typ index 783489a..ee2e17e 100644 --- a/docs/manual.typ +++ b/docs/manual.typ @@ -418,6 +418,7 @@ module, which is the view that tells you whether the run itself went well: cline(" [x] Config-DeviceManager OK 1 step(s)", fill: cGreen), cline(" [x] Config-Disk OK 2 step(s)", fill: cGreen), cline(" [x] Config-BitLocker OK 2 step(s)", fill: cGreen), + cline(" [x] Config-WindowsUpdate OK 2 step(s)", fill: cGreen), cline(" [x] Config-Network OK 3 step(s)", fill: cGreen), ) @@ -443,6 +444,8 @@ thing that had to happen on this machine: cline(" [x] Free space OK 412 GB free of 476 GB", fill: cGreen), cline(" [x] TPM readiness OK TPM present and ready.", fill: cGreen), cline(" [x] Drive encryption OK Protected: C: FullyEncrypted.", fill: cGreen), + cline(" [x] Windows Update OK No failed update in the last 50", fill: cGreen), + cline(" entries (30 days).", fill: cGreen), cline(" [x] Network adapters OK", fill: cGreen), cline(" [x] Software Center OK", fill: cGreen), cline(" [!] VPN client WARNING No matching process running (PanGPA, PanGPS).", fill: cYellow), @@ -483,6 +486,7 @@ Your answers change what each module *does*, never the order they run in. ("Config-DeviceManager", "devices Windows cannot configure"), ("Config-Disk", "disk health, free space on the system drive"), ("Config-BitLocker", "TPM readiness, encryption on the system drive"), + ("Config-WindowsUpdate", "failed updates, a restart waiting to be applied"), ("Config-Network", "adapters, connectivity, network places"), ("Config-Printer", "shared print queues"), ) @@ -508,7 +512,7 @@ Your answers change what each module *does*, never the order they run in. if i == 0 { arrow((0, -3.34), (0, y + 0.33)) } else { arrow((0, y + 0.85 - 0.33), (0, y + 0.33)) } } - let last = top - 10 * 0.85 + let last = top - 11 * 0.85 dnode((0, last - 1.0), text(size: 8pt)[Final diagnostic: by module, then by step], w: 8.4, h: 0.68) dnode((0, last - 2.0), text(size: 8pt)[Write the log, the history block and the JSON report], w: 8.4, h: 0.68) dnode((0, last - 3.0), text(size: 8.5pt, weight: "bold", fill: white)[End], @@ -520,7 +524,7 @@ Your answers change what each module *does*, never the order they run in. // Brace over the module band d.line((5.2, top + 0.33), (5.5, top + 0.33), (5.5, last - 0.33), (5.2, last - 0.33), stroke: 0.8pt + grey) - dlabel((7.1, (top + last) / 2), align(left)[11 modules, \ run in \ this order], size: 8pt) + dlabel((7.1, (top + last) / 2), align(left)[12 modules, \ run in \ this order], size: 8pt) }) ] #v(0.2cm) @@ -754,6 +758,46 @@ to say. edition does not support BitLocker, rather than crashing. ] +=== Config-WindowsUpdate — failed updates and a pending restart + +A machine that failed an update during imaging, or that is sitting on an +unapplied reboot, looks completely fine at handover and is not. Neither shows up +anywhere else in the run. + +Two steps, both reading state that is already on the machine. `Windows Update +history` reads the local update history through the Windows Update Agent and +warns about any recent entry that did not succeed, naming the update — the KB +number is part of the title Windows records — and its `HRESULT`. `Restart +pending` reads the `RebootRequired` key: absent passes, present warns. + +Only recent history counts: the last 50 entries, and only the last 30 days. A +machine re-imaged over an older install carries history that has nothing to do +with this deployment, and warning about it would be noise a technician learns to +ignore. + +#note[ + A restart is asked for *once*. If the machine was also renamed or joined to a + domain, the same restart covers both, and the `Restart required` row says so + rather than reading as though two were needed. +] + +#warn[ + "Are updates pending" is deliberately not checked. That search asks Microsoft + over the network, takes anywhere from 30 seconds to several minutes in a tool + whose whole value is being fast, and can hang. Worse, a freshly imaged machine + *always* has updates pending, because the image is weeks old — the step would + warn on 100% of runs, which is exactly the alarm technicians learn to scroll + past. "An update tried and failed" is the actionable signal; "updates exist" is + not. +] + +#note[ + The Windows Update Agent is absent or disabled on some managed and stripped + images. Creating the COM object is wrapped: that reports one note saying the + history could not be read, rather than crashing the module. Neither step needs + Administrator. +] + === Config-Network — adapters, connectivity and network places Inventories the active adapters (the virtual ones — Bluetooth, loopback, VPN, diff --git a/src/Config-WindowsUpdate.ps1 b/src/Config-WindowsUpdate.ps1 new file mode 100644 index 0000000..354f614 --- /dev/null +++ b/src/Config-WindowsUpdate.ps1 @@ -0,0 +1,282 @@ +# Config-WindowsUpdate.ps1 - failed updates, and a restart Windows is waiting for. +# Entry point: Set-WcdWindowsUpdateStatus. Requires WcdHelpers.ps1. +# +# Report only, and local only. Both Steps read state that is already on the +# machine: no network call, no timeout path, nothing that can hang a run. +# +# Deliberately absent: "are updates pending". That search asks Microsoft over +# the network, takes anywhere from 30 seconds to several minutes, can hang, and +# a freshly imaged machine always has updates pending because the image is +# weeks old - a warning on every run is the one technicians learn to scroll +# past. "An update tried and failed" is the actionable signal. + +# A machine re-imaged over an older install carries history that is not about +# this deployment, so only recent history counts. +$script:WcdUpdateHistoryLimit = 50 +$script:WcdUpdateHistoryDays = 30 + +function Get-WcdWindowsUpdateHistory { + <# + .SYNOPSIS + Returns the machine's most recent Windows Update history entries. + + .DESCRIPTION + Reads the local history through the Windows Update Agent COM object. + Nothing here searches for updates, so there is no network call and + nothing to time out. + + The agent is absent or disabled on some managed and stripped images, so + creating the COM object is wrapped: that reports Available = $false, + which the Step turns into a note rather than a crash. Availability is + carried as a property rather than by returning $null, because an empty + history and an unavailable agent are different answers. + + .PARAMETER Limit + How many entries to read, most recent first. Defaults to 50. + + .OUTPUTS + [pscustomobject] with Available and Entries. + + .EXAMPLE + (Get-WcdWindowsUpdateHistory).Entries | Where-Object { $_.ResultCode -ne 2 } + #> + [CmdletBinding()] + param( + [int]$Limit = $script:WcdUpdateHistoryLimit + ) + + try { + $session = New-Object -ComObject 'Microsoft.Update.Session' -ErrorAction Stop + } catch { + return [pscustomobject]@{ Available = $false; Entries = @() } + } + + $searcher = $session.CreateUpdateSearcher() + $total = [int]$searcher.GetTotalHistoryCount() + if ($total -le 0) { + return [pscustomobject]@{ Available = $true; Entries = @() } + } + + # QueryHistory returns the most recent first, and refuses a count larger + # than the history actually holds. + $count = [Math]::Min($Limit, $total) + return [pscustomobject]@{ Available = $true; Entries = @($searcher.QueryHistory(0, $count)) } +} + +function Test-WcdWindowsUpdateRebootPending { + <# + .SYNOPSIS + Reports whether Windows Update is waiting on a restart. + + .DESCRIPTION + Windows Update writes the RebootRequired key when an installed update + needs a restart to finish, and removes it once the machine has + restarted. Reading it needs no elevation and no network. + + .OUTPUTS + [bool] $true when the RebootRequired key exists. + + .EXAMPLE + if (Test-WcdWindowsUpdateRebootPending) { 'restart before handover' } + #> + [CmdletBinding()] + param() + + $key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' + return (Test-Path -LiteralPath $key) +} + +function Format-WcdFailedUpdateSummary { + <# + .SYNOPSIS + Renders failed update history entries into one readable sentence. + + .DESCRIPTION + Names the first few and counts the rest, the same way the Device Manager + summary does - a checklist row naming twelve updates is a row nobody + reads. The KB number is already part of the update title Windows + records, so the title carries it. + + .PARAMETER Updates + Failed history entries. + + .PARAMETER Limit + How many to name before counting the rest. Defaults to 3. + + .OUTPUTS + [string] The summary. + + .EXAMPLE + Format-WcdFailedUpdateSummary -Updates $failed + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Updates, + + [int]$Limit = 3 + ) + + $preview = @($Updates | Select-Object -First $Limit | ForEach-Object { + $title = [string]$_.Title + if ([string]::IsNullOrWhiteSpace($title)) { $title = 'Unnamed update' } + + '{0} (HRESULT 0x{1:X8})' -f $title, ([int]$_.HResult) + }) + + $summary = $preview -join ', ' + if (@($Updates).Count -gt $Limit) { + $summary = '{0}, +{1} more' -f $summary, (@($Updates).Count - $Limit) + } + + return $summary +} + +function Set-WcdWindowsUpdateStatus { + <# + .SYNOPSIS + Reports failed Windows updates and a restart waiting to be applied. + + .DESCRIPTION + A machine that failed an update during imaging, or that is sitting on an + unapplied reboot, looks perfectly fine at handover and is not. Two Steps, + both reading local state only. + + WindowsUpdateHistory reads the recent history and warns about any entry + that did not succeed, naming the update and its HRESULT. Only the last + 50 entries, and only the last 30 days, count: a machine re-imaged over an + older install carries history that is not about this deployment. + + WindowsUpdateReboot reads the RebootRequired key. The tool never + restarts the machine itself - a reboot mid-run would destroy the + checklist, the history log and the JSON report - so it says so and lets + the technician do it. + + Report only, and neither Step needs Administrator. + + .PARAMETER LogPath + Full path to the log file. Resolved automatically when omitted. + + .PARAMETER ProgressCallback + Scriptblock invoked at the start and end of each step for progress display. + + .OUTPUTS + [pscustomobject[]] with Step, Success, Severity, Error and optionally + RemedyKey and RemedyArgs, for WindowsUpdateHistory and + WindowsUpdateReboot. The reboot Result also carries RebootPending, which + the checklist uses to fold it into the single restart row. + + .EXAMPLE + Set-WcdWindowsUpdateStatus -LogPath 'C:\temp\log.txt' + #> + [CmdletBinding()] + param( + [string]$LogPath, + [scriptblock]$ProgressCallback + ) + + $resolvedLogPath = Resolve-WcdLogPath -CandidatePath $LogPath + $moduleName = 'Config-WindowsUpdate' + $results = @() + + # --- Recent update history ----------------------------------------------- + Invoke-WcdProgressCallback -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey 'WindowsUpdateHistory' -Event 'Start' + + try { + $history = Get-WcdWindowsUpdateHistory -Limit $script:WcdUpdateHistoryLimit + + if (-not $history.Available) { + $message = 'The Windows Update Agent is not available on this image; the update history could not be read.' + Write-WcdLog -Path $resolvedLogPath -Level 'INFO' -Message ('Windows Update: {0}' -f $message) + $results += [pscustomobject]@{ + Step = 'WindowsUpdateHistory' + Success = $true + Severity = 'INFO' + Error = $message + } + } else { + $cutoff = [DateTime]::UtcNow.AddDays(-$script:WcdUpdateHistoryDays) + # ResultCode 2 is Succeeded. Anything else in recent history is an + # update that tried and did not get there. + $failed = @($history.Entries | Where-Object { + $null -ne $_ -and [int]$_.ResultCode -ne 2 -and ([DateTime]$_.Date) -ge $cutoff + }) + + if ($failed.Count -gt 0) { + $summary = Format-WcdFailedUpdateSummary -Updates $failed + $message = 'Windows updates that did not succeed ({0}): {1}' -f $failed.Count, $summary + Write-WcdLog -Path $resolvedLogPath -Level 'WARNING' -Message ('Windows Update: {0}' -f $message) + $results += [pscustomobject]@{ + Step = 'WindowsUpdateHistory' + Success = $true + Severity = 'WARNING' + Error = $message + RemedyKey = 'WindowsUpdateFailed' + RemedyArgs = @() + } + } else { + $message = 'No failed update in the last {0} entries ({1} days).' -f $script:WcdUpdateHistoryLimit, $script:WcdUpdateHistoryDays + Write-WcdLog -Path $resolvedLogPath -Level 'INFO' -Message ('Windows Update: {0}' -f $message) + $results += [pscustomobject]@{ + Step = 'WindowsUpdateHistory' + Success = $true + Severity = 'INFO' + Error = $message + } + } + } + } catch { + $message = 'The Windows update history could not be read: {0}' -f $_.Exception.Message + Write-WcdLog -Path $resolvedLogPath -Level 'ERROR' -Message $message + $results += [pscustomobject]@{ + Step = 'WindowsUpdateHistory' + Success = $false + Severity = 'ERROR' + Error = $message + } + } + + Complete-WcdProgressStep -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey 'WindowsUpdateHistory' -Results $results + + # --- A restart Windows is waiting for ------------------------------------ + Invoke-WcdProgressCallback -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey 'WindowsUpdateReboot' -Event 'Start' + + try { + if (Test-WcdWindowsUpdateRebootPending) { + $message = 'An installed update is waiting on a restart.' + Write-WcdLog -Path $resolvedLogPath -Level 'WARNING' -Message ('Windows Update: {0}' -f $message) + $results += [pscustomobject]@{ + Step = 'WindowsUpdateReboot' + Success = $true + Severity = 'WARNING' + Error = $message + RemedyKey = 'RebootPending' + # Read by the checklist, which folds this into the one restart + # row rather than asking for a second restart of its own. + RebootPending = $true + } + } else { + Write-WcdLog -Path $resolvedLogPath -Level 'INFO' -Message 'Windows Update: no restart pending.' + $results += [pscustomobject]@{ + Step = 'WindowsUpdateReboot' + Success = $true + Severity = 'INFO' + Error = 'No restart pending.' + RebootPending = $false + } + } + } catch { + $message = 'The pending-restart state could not be read: {0}' -f $_.Exception.Message + Write-WcdLog -Path $resolvedLogPath -Level 'ERROR' -Message $message + $results += [pscustomobject]@{ + Step = 'WindowsUpdateReboot' + Success = $false + Severity = 'ERROR' + Error = $message + } + } + + Complete-WcdProgressStep -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey 'WindowsUpdateReboot' -Results $results + + return $results +} diff --git a/src/Invoke-WcdConfiguration.ps1 b/src/Invoke-WcdConfiguration.ps1 index a712074..4a9ba51 100644 --- a/src/Invoke-WcdConfiguration.ps1 +++ b/src/Invoke-WcdConfiguration.ps1 @@ -164,6 +164,7 @@ $T = if ($ScriptUI -eq 'EN') { Favorites = 'Browser favorites' Network = 'Network adapters' Winget = 'App Installer (winget)' + WindowsUpdate = 'Windows Update' DiskHealth = 'Disk health' DiskFreeSpace = 'Free space' Tpm = 'TPM readiness' @@ -241,6 +242,8 @@ $T = if ($ScriptUI -eq 'EN') { StandardManualDetail = 'Must be done manually.' IdentityManualDetail = 'Not requested this run. Must be done manually if the machine needs it.' RestartManualDetail = 'New computer name / domain membership takes effect after a restart.' + RestartUpdateManualDetail = 'An installed Windows update is waiting on a restart.' + RestartBothManualDetail = 'One restart covers both: the new computer name / domain membership, and an installed Windows update waiting on it.' SecondaryNA = 'Not applicable to the chosen Form Factor or Environment.' DeskWindowsDetail = 'Must be done manually on the Windows desktop.' StepCount = 'step(s)' @@ -287,6 +290,8 @@ $T = if ($ScriptUI -eq 'EN') { WingetMissing = 'App Installer (winget) is not provisioned on this image, so the packages below could not be checked. Verify them by hand, or install App Installer from the Microsoft Store.' WingetPackageMissing = "winget does not list {0} as installed. Confirm imaging delivered it, or fix the package id in Applications['{1}'].Target in WinContextDeploy.psd1." WingetCheckFailed = "winget could not check {0}. Run 'winget list --id {1} --exact' by hand to see why." + WindowsUpdateFailed = 'Run Windows Update by hand and let the failed update through before handover.' + RebootPending = 'Restart the machine before handover; the update is not finished until it does.' UnknownAction = "Unknown Action '{0}' for step '{1}'. Valid: {2}." RequiresAdmin = 'Requires Administrator. Relaunch elevated to apply.' PowerCfgFailed = 'powercfg refused the change. Check that no Group Policy pins the power plan.' @@ -334,6 +339,7 @@ $T = if ($ScriptUI -eq 'EN') { Favorites = 'Favoris du navigateur' Network = 'Adaptateurs reseau' Winget = 'App Installer (winget)' + WindowsUpdate = 'Windows Update' DiskHealth = 'Sante du disque' DiskFreeSpace = 'Espace libre' Tpm = 'Etat du TPM' @@ -411,6 +417,8 @@ $T = if ($ScriptUI -eq 'EN') { StandardManualDetail = 'A faire manuellement.' IdentityManualDetail = 'Non demande cette fois. A faire manuellement si le poste en a besoin.' RestartManualDetail = 'Le nouveau nom du poste et l appartenance au domaine prennent effet apres un redemarrage.' + RestartUpdateManualDetail = 'Une mise a jour Windows installee attend un redemarrage.' + RestartBothManualDetail = 'Un seul redemarrage suffit: le nouveau nom du poste et l appartenance au domaine, et une mise a jour Windows installee qui l attend.' SecondaryNA = 'Non applicable au type de poste ou a l usage choisi.' DeskWindowsDetail = 'A faire manuellement sur le bureau Windows.' StepCount = 'etape(s)' @@ -457,6 +465,8 @@ $T = if ($ScriptUI -eq 'EN') { WingetMissing = 'App Installer (winget) n est pas provisionne sur cette image, donc les paquets ci-dessous n ont pas pu etre verifies. Les verifier a la main, ou installer App Installer depuis le Microsoft Store.' WingetPackageMissing = "winget ne liste pas {0} comme installe. Confirmer que l imagerie l a livre, ou corriger l identifiant de paquet dans Applications['{1}'].Target dans WinContextDeploy.psd1." WingetCheckFailed = "winget n a pas pu verifier {0}. Lancer 'winget list --id {1} --exact' a la main pour voir pourquoi." + WindowsUpdateFailed = 'Lancer Windows Update a la main et laisser passer la mise a jour en echec avant la remise du poste.' + RebootPending = 'Redemarrer le poste avant la remise; la mise a jour n est pas terminee tant qu il ne l est pas.' UnknownAction = "Action '{0}' inconnue pour l etape '{1}'. Valides: {2}." RequiresAdmin = 'Exige les droits Administrateur. Relancer en tant qu administrateur pour appliquer.' PowerCfgFailed = 'powercfg a refuse la modification. Verifier qu aucune GPO ne fige le mode de gestion d alimentation.' @@ -1696,8 +1706,21 @@ function Get-WcdFinalChecklistEntries { # has succeeds without changing anything a restart would take effect for. $identityApplied = @(Get-WcdResultsForSteps -ResultLookup $lookup -StepKeys @('ComputerName', 'DomainJoin') | Where-Object { $_.Applied }) - if ($identityApplied.Count -gt 0) { - $entries += New-WcdDiagnosticEntry -Label $T.Checklist.RestartNeeded -Kind 'manual' -Detail $T.RestartManualDetail + # An update waiting on a restart needs the same restart the rename does, so + # the row is raised once and names whichever causes apply. Two restart rows + # would read as two restarts. + $updateRebootPending = @(Get-WcdResultsForSteps -ResultLookup $lookup -StepKeys @('WindowsUpdateReboot') | + Where-Object { $_.RebootPending }) + if ($identityApplied.Count -gt 0 -or $updateRebootPending.Count -gt 0) { + $restartDetail = if ($identityApplied.Count -gt 0 -and $updateRebootPending.Count -gt 0) { + $T.RestartBothManualDetail + } elseif ($updateRebootPending.Count -gt 0) { + $T.RestartUpdateManualDetail + } else { + $T.RestartManualDetail + } + + $entries += New-WcdDiagnosticEntry -Label $T.Checklist.RestartNeeded -Kind 'manual' -Detail $restartDetail } # --- OS configuration, in the order the modules run ----------------------- @@ -1727,6 +1750,11 @@ function Get-WcdFinalChecklistEntries { -StepKeys @('TpmReadiness') -StepLabels $StepLabels $entries += Resolve-WcdAutomaticEntry -Label $T.Checklist.BitLocker -ResultLookup $lookup ` -StepKeys @('BitLockerStatus') -StepLabels $StepLabels + # One row for both update Steps: a failed update and a pending restart are + # the same conversation with the technician, and the restart itself is asked + # for once, in the restart row above. + $entries += Resolve-WcdAutomaticEntry -Label $T.Checklist.WindowsUpdate -ResultLookup $lookup ` + -StepKeys @('WindowsUpdateHistory', 'WindowsUpdateReboot') -StepLabels $StepLabels $entries += Resolve-WcdAutomaticEntry -Label $T.Checklist.Network -ResultLookup $lookup ` -StepKeys @('NetworkAdapterStatus', 'NetworkPing8888', 'RefreshNetworkPlaces') -StepLabels $StepLabels @@ -1861,6 +1889,7 @@ $modules = @( @{ Name = 'Config-DeviceManager'; File = 'Config-DeviceManager.ps1' }, @{ Name = 'Config-Disk'; File = 'Config-Disk.ps1' }, @{ Name = 'Config-BitLocker'; File = 'Config-BitLocker.ps1' }, + @{ Name = 'Config-WindowsUpdate'; File = 'Config-WindowsUpdate.ps1' }, @{ Name = 'Config-Network'; File = 'Config-Network.ps1' }, @{ Name = 'Config-Printer'; File = 'Config-Printer.ps1' } ) @@ -1968,6 +1997,9 @@ foreach ($mod in $modules) { 'Config-BitLocker' { $modResults = @(Set-WcdBitLockerStatus -Elevated $isElevated -LogPath $resolvedLogPath -ProgressCallback $progressCallback) } + 'Config-WindowsUpdate' { + $modResults = @(Set-WcdWindowsUpdateStatus -LogPath $resolvedLogPath -ProgressCallback $progressCallback) + } 'Config-Network' { $modResults = @(Set-WcdNetworkDiagnostics -Config $script:WcdConfig -LogPath $resolvedLogPath -ProgressCallback $progressCallback) } diff --git a/src/WcdHelpers.ps1 b/src/WcdHelpers.ps1 index a015d19..2bf8d0a 100644 --- a/src/WcdHelpers.ps1 +++ b/src/WcdHelpers.ps1 @@ -569,6 +569,8 @@ function Get-WcdTechnicalStepLabels { 'BitLockerStatus' = 'Drive encryption' 'ComputerName' = 'Computer name' 'DomainJoin' = 'Domain join' + 'WindowsUpdateHistory' = 'Windows Update history' + 'WindowsUpdateReboot' = 'Restart pending' 'NetworkAdapterStatus' = 'Network adapters' 'NetworkPing8888' = 'Connectivity test' 'RefreshNetworkPlaces' = 'Refresh network places' @@ -667,6 +669,7 @@ function Get-WcdModuleProgressPlan { 'Config-DeviceManager' = @('DeviceManagerStatus') 'Config-Disk' = @('DiskHealth', 'DiskFreeSpace') 'Config-BitLocker' = @('TpmReadiness', 'BitLockerStatus') + 'Config-WindowsUpdate' = @('WindowsUpdateHistory', 'WindowsUpdateReboot') 'Config-Network' = @('NetworkAdapterStatus', 'NetworkPing8888', 'RefreshNetworkPlaces') 'Config-Printer' = $printerSteps } diff --git a/tests/Config-WindowsUpdate.Tests.ps1 b/tests/Config-WindowsUpdate.Tests.ps1 new file mode 100644 index 0000000..1268ba5 --- /dev/null +++ b/tests/Config-WindowsUpdate.Tests.ps1 @@ -0,0 +1,156 @@ +Describe 'Config-WindowsUpdate' { + BeforeAll { + $srcDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'src' + $helpersPath = Join-Path $srcDir 'WcdHelpers.ps1' + $modulePath = Join-Path $srcDir 'Config-WindowsUpdate.ps1' + + if (-not (Test-Path -LiteralPath $helpersPath)) { throw 'WcdHelpers.ps1 introuvable.' } + if (-not (Test-Path -LiteralPath $modulePath)) { throw 'Config-WindowsUpdate.ps1 introuvable.' } + + . $helpersPath + . $modulePath + + # Une entree d historique telle que QueryHistory la retourne. + function script:New-TestHistoryEntry { + param( + [string]$Title = '2026-08 Cumulative Update for Windows 11 (KB5031354)', + [int]$ResultCode = 2, + [int]$HResult = 0, + [int]$DaysAgo = 1 + ) + return [pscustomobject]@{ + Title = $Title + ResultCode = $ResultCode + HResult = $HResult + Date = [DateTime]::UtcNow.AddDays(-$DaysAgo) + } + } + + function script:New-TestHistory { + param([object[]]$Entries = @(), [bool]$Available = $true) + return [pscustomobject]@{ Available = $Available; Entries = @($Entries) } + } + } + + Context 'WindowsUpdateHistory' { + It 'retourne OK quand l historique recent est propre' { + $logPath = Join-Path $TestDrive 'log_wu_clean.txt' + Mock -CommandName 'Get-WcdWindowsUpdateHistory' { New-TestHistory -Entries @(New-TestHistoryEntry) } + Mock -CommandName 'Test-WcdWindowsUpdateRebootPending' { $false } + + $history = @(Set-WcdWindowsUpdateStatus -LogPath $logPath | Where-Object { $_.Step -eq 'WindowsUpdateHistory' })[0] + + $history.Success | Should -BeTrue + $history.Severity | Should -Be 'INFO' + } + + It 'avertit et nomme la mise a jour en echec avec son HRESULT' { + $logPath = Join-Path $TestDrive 'log_wu_one.txt' + Mock -CommandName 'Get-WcdWindowsUpdateHistory' { + New-TestHistory -Entries @( + New-TestHistoryEntry + New-TestHistoryEntry -Title 'Security Update (KB5030219)' -ResultCode 4 -HResult -2145124329 + ) + } + Mock -CommandName 'Test-WcdWindowsUpdateRebootPending' { $false } + + $history = @(Set-WcdWindowsUpdateStatus -LogPath $logPath | Where-Object { $_.Step -eq 'WindowsUpdateHistory' })[0] + + $history.Severity | Should -Be 'WARNING' + # une etape qui rapporte n est pas une etape cassee + $history.Success | Should -BeTrue + $history.Error | Should -Match 'KB5030219' + $history.Error | Should -Match '0x80240017' + $history.RemedyKey | Should -Be 'WindowsUpdateFailed' + } + + It 'compte les echecs et n en nomme que les premiers' { + $logPath = Join-Path $TestDrive 'log_wu_many.txt' + Mock -CommandName 'Get-WcdWindowsUpdateHistory' { + New-TestHistory -Entries @(1..5 | ForEach-Object { + New-TestHistoryEntry -Title ("Update KB50000{0}" -f $_) -ResultCode 4 -HResult -2145124329 + }) + } + Mock -CommandName 'Test-WcdWindowsUpdateRebootPending' { $false } + + $history = @(Set-WcdWindowsUpdateStatus -LogPath $logPath | Where-Object { $_.Step -eq 'WindowsUpdateHistory' })[0] + + $history.Severity | Should -Be 'WARNING' + $history.Error | Should -Match '\(5\)' + $history.Error | Should -Match '\+2 more' + } + + It 'ignore l historique plus vieux que la fenetre retenue' { + $logPath = Join-Path $TestDrive 'log_wu_old.txt' + Mock -CommandName 'Get-WcdWindowsUpdateHistory' { + # Un poste reimage par-dessus une ancienne installation porte un + # historique qui ne concerne pas ce deploiement. + New-TestHistory -Entries @(New-TestHistoryEntry -ResultCode 4 -HResult -2145124329 -DaysAgo 90) + } + Mock -CommandName 'Test-WcdWindowsUpdateRebootPending' { $false } + + $history = @(Set-WcdWindowsUpdateStatus -LogPath $logPath | Where-Object { $_.Step -eq 'WindowsUpdateHistory' })[0] + + $history.Severity | Should -Be 'INFO' + } + + It 'traite un agent Windows Update absent comme une note, pas un plantage' { + $logPath = Join-Path $TestDrive 'log_wu_nocom.txt' + Mock -CommandName 'Get-WcdWindowsUpdateHistory' { New-TestHistory -Available $false } + Mock -CommandName 'Test-WcdWindowsUpdateRebootPending' { $false } + + $history = @(Set-WcdWindowsUpdateStatus -LogPath $logPath | Where-Object { $_.Step -eq 'WindowsUpdateHistory' })[0] + + $history.Success | Should -BeTrue + $history.Severity | Should -Be 'INFO' + $history.Error | Should -Match 'Windows Update Agent' + } + } + + Context 'WindowsUpdateReboot' { + BeforeEach { + Mock -CommandName 'Get-WcdWindowsUpdateHistory' { New-TestHistory -Entries @(New-TestHistoryEntry) } + } + + It 'avertit quand la cle RebootRequired existe' { + $logPath = Join-Path $TestDrive 'log_wu_reboot.txt' + Mock -CommandName 'Test-WcdWindowsUpdateRebootPending' { $true } + + $reboot = @(Set-WcdWindowsUpdateStatus -LogPath $logPath | Where-Object { $_.Step -eq 'WindowsUpdateReboot' })[0] + + $reboot.Severity | Should -Be 'WARNING' + $reboot.Success | Should -BeTrue + $reboot.RemedyKey | Should -Be 'RebootPending' + # lu par la checklist, qui n affiche qu une seule ligne de redemarrage + $reboot.RebootPending | Should -BeTrue + } + + It 'retourne OK quand la cle est absente' { + $logPath = Join-Path $TestDrive 'log_wu_noreboot.txt' + Mock -CommandName 'Test-WcdWindowsUpdateRebootPending' { $false } + + $reboot = @(Set-WcdWindowsUpdateStatus -LogPath $logPath | Where-Object { $_.Step -eq 'WindowsUpdateReboot' })[0] + + $reboot.Severity | Should -Be 'INFO' + $reboot.RebootPending | Should -BeFalse + } + } + + Context 'Format-WcdFailedUpdateSummary' { + It 'nomme le titre et le HRESULT en hexadecimal' { + $summary = Format-WcdFailedUpdateSummary -Updates @( + New-TestHistoryEntry -Title 'Security Update (KB5030219)' -ResultCode 4 -HResult -2145124329 + ) + + $summary | Should -Be 'Security Update (KB5030219) (HRESULT 0x80240017)' + } + + It 'remplace un titre vide plutot que de laisser une ligne muette' { + $summary = Format-WcdFailedUpdateSummary -Updates @( + New-TestHistoryEntry -Title '' -ResultCode 4 -HResult 0 + ) + + $summary | Should -Match 'Unnamed update' + } + } +}