diff --git a/resources/WindowsUpdate/locales/en-us.toml b/resources/WindowsUpdate/locales/en-us.toml index 4c2129bd7..45639e018 100644 --- a/resources/WindowsUpdate/locales/en-us.toml +++ b/resources/WindowsUpdate/locales/en-us.toml @@ -47,6 +47,7 @@ noMatchingUpdateForCriteria = "No matching update found for criteria: %{criteria failedDownloadUpdate = "Failed to download update. Result code: %{code}" failedInstallUpdate = "Failed to install update. Result code: %{code}" failedSerializeOutput = "Failed to serialize output: %{err}" +whatIfInstallUpdate = "Would install update '%{title}'" criteriaTitle = "title '%{value}'" criteriaId = "id '%{value}'" criteriaIsInstalled = "is_installed %{value}" diff --git a/resources/WindowsUpdate/src/main.rs b/resources/WindowsUpdate/src/main.rs index be51553e1..07e9e9c78 100644 --- a/resources/WindowsUpdate/src/main.rs +++ b/resources/WindowsUpdate/src/main.rs @@ -81,7 +81,7 @@ fn main() { } #[cfg(windows)] - match windows_update::handle_set(&buffer) { + match windows_update::handle_set(&buffer, parse_what_if_arg(&args)) { Ok(output) => { println!("{}", output); std::process::exit(0); @@ -105,3 +105,37 @@ fn main() { } } } + +#[cfg(windows)] +fn parse_what_if_arg(args: &[String]) -> bool { + args.iter().any(|arg| arg == "-w" || arg == "--what-if") +} + +#[cfg(all(test, windows))] +mod tests { + use super::parse_what_if_arg; + + fn to_args(args: &[&str]) -> Vec { + args.iter().map(ToString::to_string).collect() + } + + #[test] + fn detects_short_what_if_flag() { + assert!(parse_what_if_arg(&to_args(&["windows_update", "set", "-w"]))); + } + + #[test] + fn detects_long_what_if_flag() { + assert!(parse_what_if_arg(&to_args(&["windows_update", "set", "--what-if"]))); + } + + #[test] + fn returns_false_without_what_if_flag() { + assert!(!parse_what_if_arg(&to_args(&["windows_update", "set"]))); + } + + #[test] + fn does_not_match_similar_arguments() { + assert!(!parse_what_if_arg(&to_args(&["windows_update", "set", "-what-if", "--w", "what-if"]))); + } +} diff --git a/resources/WindowsUpdate/src/windows_update/export.rs b/resources/WindowsUpdate/src/windows_update/export.rs index 81ab9b6ba..514331bdc 100644 --- a/resources/WindowsUpdate/src/windows_update/export.rs +++ b/resources/WindowsUpdate/src/windows_update/export.rs @@ -18,6 +18,7 @@ pub fn handle_export(input: &str) -> Result { UpdateList { restart_required: None, updates: vec![UpdateInfo { + metadata: None, description: None, id: None, installation_behavior: None, diff --git a/resources/WindowsUpdate/src/windows_update/set.rs b/resources/WindowsUpdate/src/windows_update/set.rs index 5528b826c..758af6b05 100644 --- a/resources/WindowsUpdate/src/windows_update/set.rs +++ b/resources/WindowsUpdate/src/windows_update/set.rs @@ -10,14 +10,23 @@ use windows::{ Win32::System::UpdateAgent::*, }; -use crate::windows_update::types::{UpdateList, UpdateInfo, extract_update_info}; +use crate::windows_update::types::{UpdateList, UpdateInfo, Metadata, extract_update_info}; /// Gets the computer name using the COMPUTERNAME environment variable fn get_computer_name() -> String { std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()) } -pub fn handle_set(input: &str) -> Result { +fn project_what_if_install(mut info: UpdateInfo) -> UpdateInfo { + let title = info.title.clone().unwrap_or_default(); + info.is_installed = Some(true); + info.metadata = Some(Metadata { + what_if: Some(vec![t!("set.whatIfInstallUpdate", title = title).to_string()]), + }); + info +} + +pub fn handle_set(input: &str, what_if: bool) -> Result { // Parse input as UpdateList let update_list: UpdateList = serde_json::from_str(input) .map_err(|e| Error::new(E_INVALIDARG, t!("set.failedParseInput", err = e.to_string())))?; @@ -217,6 +226,9 @@ pub fn handle_set(input: &str) -> Result { let update_info = if is_installed { // Already installed, just return current state extract_update_info(&update)? + } else if what_if { + // What-if: project the state after installation without making changes + project_what_if_install(extract_update_info(&update)?) } else { // Not installed - proceed with installation // Create update collection for download/install @@ -301,3 +313,57 @@ pub fn handle_set(input: &str) -> Result { Err(e) => Err(e), } } + +#[cfg(test)] +mod tests { + use super::project_what_if_install; + use crate::windows_update::types::UpdateInfo; + + fn test_update_info() -> UpdateInfo { + UpdateInfo { + metadata: None, + description: None, + id: Some("aeb14a2c-5540-481a-8a4d-6a4f9b962692".to_string()), + installation_behavior: None, + is_installed: Some(false), + is_uninstallable: None, + kb_article_ids: None, + msrc_severity: None, + recommended_hard_disk_space: None, + security_bulletin_ids: None, + title: Some("Test Update".to_string()), + update_type: None, + } + } + + #[test] + fn projects_installed_state_with_what_if_message() { + let projected = project_what_if_install(test_update_info()); + + assert_eq!(projected.is_installed, Some(true)); + let metadata = projected.metadata.expect("metadata should be set"); + let messages = metadata.what_if.expect("whatIf messages should be set"); + assert_eq!(messages.len(), 1); + assert!(messages[0].contains("Test Update")); + } + + #[test] + fn preserves_other_fields() { + let projected = project_what_if_install(test_update_info()); + + assert_eq!(projected.id.as_deref(), Some("aeb14a2c-5540-481a-8a4d-6a4f9b962692")); + assert_eq!(projected.title.as_deref(), Some("Test Update")); + assert!(projected.description.is_none()); + } + + #[test] + fn handles_missing_title() { + let mut info = test_update_info(); + info.title = None; + + let projected = project_what_if_install(info); + + assert_eq!(projected.is_installed, Some(true)); + assert!(projected.metadata.and_then(|m| m.what_if).is_some()); + } +} diff --git a/resources/WindowsUpdate/src/windows_update/types.rs b/resources/WindowsUpdate/src/windows_update/types.rs index 99408dfbd..76ce45584 100644 --- a/resources/WindowsUpdate/src/windows_update/types.rs +++ b/resources/WindowsUpdate/src/windows_update/types.rs @@ -12,9 +12,17 @@ pub struct UpdateList { pub updates: Vec, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Metadata { + #[serde(rename = "whatIf", skip_serializing_if = "Option::is_none")] + pub what_if: Option>, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct UpdateInfo { + #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -175,6 +183,7 @@ pub fn extract_update_info(update: &IUpdate) -> Result { }; Ok(UpdateInfo { + metadata: None, title: Some(title), is_installed: Some(is_installed), description: Some(description), @@ -189,3 +198,85 @@ pub fn extract_update_info(update: &IUpdate) -> Result { }) } } + +#[cfg(test)] +mod tests { + use super::{Metadata, UpdateInfo, UpdateList}; + + #[test] + fn metadata_serializes_with_what_if_field() { + let metadata = Metadata { + what_if: Some(vec!["Would install update 'Test'".to_string()]), + }; + + let json = serde_json::to_string(&metadata).expect("serialization should succeed"); + assert_eq!(json, r#"{"whatIf":["Would install update 'Test'"]}"#); + } + + #[test] + fn metadata_skips_empty_what_if_field() { + let metadata = Metadata { what_if: None }; + + let json = serde_json::to_string(&metadata).expect("serialization should succeed"); + assert_eq!(json, "{}"); + } + + #[test] + fn update_info_serializes_metadata_as_underscore_metadata() { + let info = UpdateInfo { + metadata: Some(Metadata { + what_if: Some(vec!["Would install update 'Test'".to_string()]), + }), + description: None, + id: Some("some-id".to_string()), + installation_behavior: None, + is_installed: Some(true), + is_uninstallable: None, + kb_article_ids: None, + msrc_severity: None, + recommended_hard_disk_space: None, + security_bulletin_ids: None, + title: None, + update_type: None, + }; + + let value = serde_json::to_value(&info).expect("serialization should succeed"); + assert_eq!(value["_metadata"]["whatIf"][0], "Would install update 'Test'"); + assert_eq!(value["id"], "some-id"); + assert_eq!(value["isInstalled"], true); + } + + #[test] + fn update_info_skips_metadata_when_none() { + let info = UpdateInfo { + metadata: None, + description: None, + id: Some("some-id".to_string()), + installation_behavior: None, + is_installed: None, + is_uninstallable: None, + kb_article_ids: None, + msrc_severity: None, + recommended_hard_disk_space: None, + security_bulletin_ids: None, + title: None, + update_type: None, + }; + + let value = serde_json::to_value(&info).expect("serialization should succeed"); + assert!(value.get("_metadata").is_none()); + } + + #[test] + fn update_list_round_trips_metadata() { + let json = r#"{"updates":[{"id":"some-id","_metadata":{"whatIf":["message"]}}]}"#; + + let list: UpdateList = serde_json::from_str(json).expect("deserialization should succeed"); + let metadata = list.updates[0].metadata.as_ref().expect("metadata should be present"); + assert_eq!(metadata.what_if.as_deref(), Some(&["message".to_string()][..])); + + let round_tripped = serde_json::to_string(&list).expect("serialization should succeed"); + let reparsed: UpdateList = serde_json::from_str(&round_tripped).expect("round-trip should succeed"); + assert_eq!(reparsed.updates[0].id.as_deref(), Some("some-id")); + } +} diff --git a/resources/WindowsUpdate/tests/windowsupdate_whatif.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate_whatif.tests.ps1 new file mode 100644 index 000000000..4ba35b2e7 --- /dev/null +++ b/resources/WindowsUpdate/tests/windowsupdate_whatif.tests.ps1 @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Update WhatIf operation tests' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/UpdateList' + } + + Context 'Set what-if operation' -Skip:(!$isAdmin -or !$IsWindows) { + It 'Can whatif installing an update without installing it' { + # Find an update that is not installed + $exportOut = '{"updates": [{"isInstalled": false}]}' | dsc resource export -r $resourceType -f - 2>&1 + + if ($LASTEXITCODE -ne 0) { + Set-ItResult -Skipped -Because 'export operation failed' + return + } + + $exported = $exportOut | ConvertFrom-Json + if ($exported.updates.Count -eq 0) { + Set-ItResult -Skipped -Because 'no uninstalled updates are available' + return + } + + $testUpdate = $exported.updates[0] + $json = @{ + updates = @( + @{ + id = $testUpdate.id + } + ) + } | ConvertTo-Json -Depth 10 -Compress + + $out = $json | dsc resource set -r $resourceType -f - -w 2>&1 + $LASTEXITCODE | Should -Be 0 + + $result = $out | ConvertFrom-Json + $result.afterState.updates[0].id | Should -Be $testUpdate.id + $result.afterState.updates[0].isInstalled | Should -Be $true + $result.afterState.updates[0]._metadata.whatIf | Should -Not -BeNullOrEmpty + $result.afterState.updates[0]._metadata.whatIf | Should -Contain "Would install update '$($testUpdate.title)'" + + # Assert no mutation happened + $getOut = $json | dsc resource get -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Be 0 + ($getOut | ConvertFrom-Json).actualState.updates[0].isInstalled | Should -Be $false + } + + It 'Returns current state without whatIf messages for an already installed update' { + # Find an update that is already installed + $exportOut = '{"updates": [{"isInstalled": true}]}' | dsc resource export -r $resourceType -f - 2>&1 + + if ($LASTEXITCODE -ne 0) { + Set-ItResult -Skipped -Because 'export operation failed' + return + } + + $exported = $exportOut | ConvertFrom-Json + if ($exported.updates.Count -eq 0) { + Set-ItResult -Skipped -Because 'no installed updates are available' + return + } + + $testUpdate = $exported.updates[0] + $json = @{ + updates = @( + @{ + id = $testUpdate.id + } + ) + } | ConvertTo-Json -Depth 10 -Compress + + $out = $json | dsc resource set -r $resourceType -f - -w 2>&1 + $LASTEXITCODE | Should -Be 0 + + $result = $out | ConvertFrom-Json + $result.afterState.updates[0].id | Should -Be $testUpdate.id + $result.afterState.updates[0].isInstalled | Should -Be $true + $result.afterState.updates[0]._metadata | Should -BeNullOrEmpty + } + } +} diff --git a/resources/WindowsUpdate/windowsupdate.dsc.resource.json b/resources/WindowsUpdate/windowsupdate.dsc.resource.json index 853f9ca56..708bf8b28 100644 --- a/resources/WindowsUpdate/windowsupdate.dsc.resource.json +++ b/resources/WindowsUpdate/windowsupdate.dsc.resource.json @@ -8,7 +8,7 @@ "security" ], "type": "Microsoft.Windows/UpdateList", - "version": "0.1.0", + "version": "0.1.1", "get": { "executable": "wu_dsc", "args": [ @@ -19,11 +19,16 @@ "set": { "executable": "wu_dsc", "args": [ - "set" + "set", + { + "whatIfArg": "--what-if" + } ], "input": "stdin", - "preTest": true, - "return": "state" + "implementsPretest": true, + "requireSecurityContext": "elevated", + "return": "state", + "whatIfReturns": "state" }, "export": { "executable": "wu_dsc", @@ -138,6 +143,20 @@ "title": "Installation behavior", "description": "Indicates the reboot behavior expected from installing this update. NeverReboots means the update never requires a reboot, AlwaysRequiresReboot means it always requires one, and CanRequestReboot means it may request a reboot.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#installationbehavior\n", "markdownDescription": "Indicates the reboot behavior expected from installing this update.\n\n- **NeverReboots**: The update never requires a reboot\n- **AlwaysRequiresReboot**: The update always requires a reboot\n- **CanRequestReboot**: The update may request a reboot\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#installationbehavior\n" + }, + "_metadata": { + "type": "object", + "title": "Metadata", + "description": "Metadata populated during what-if operations.", + "readOnly": true, + "additionalProperties": false, + "properties": { + "whatIf": { + "type": "array", + "description": "Messages describing what the set operation would do.", + "items": { "type": "string" } + } + } } } }