Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions resources/WindowsUpdate/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
36 changes: 35 additions & 1 deletion resources/WindowsUpdate/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<String> {
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"])));
}
}
1 change: 1 addition & 0 deletions resources/WindowsUpdate/src/windows_update/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub fn handle_export(input: &str) -> Result<String> {
UpdateList {
restart_required: None,
updates: vec![UpdateInfo {
metadata: None,
description: None,
id: None,
installation_behavior: None,
Expand Down
70 changes: 68 additions & 2 deletions resources/WindowsUpdate/src/windows_update/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<String> {
// 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())))?;
Expand Down Expand Up @@ -217,6 +226,9 @@ pub fn handle_set(input: &str) -> Result<String> {
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
Expand Down Expand Up @@ -301,3 +313,57 @@ pub fn handle_set(input: &str) -> Result<String> {
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());
}
}
91 changes: 91 additions & 0 deletions resources/WindowsUpdate/src/windows_update/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,17 @@ pub struct UpdateList {
pub updates: Vec<UpdateInfo>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Metadata {
#[serde(rename = "whatIf", skip_serializing_if = "Option::is_none")]
pub what_if: Option<Vec<String>>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
#[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")]
pub metadata: Option<Metadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -175,6 +183,7 @@ pub fn extract_update_info(update: &IUpdate) -> Result<UpdateInfo> {
};

Ok(UpdateInfo {
metadata: None,
title: Some(title),
is_installed: Some(is_installed),
description: Some(description),
Expand All @@ -189,3 +198,85 @@ pub fn extract_update_info(update: &IUpdate) -> Result<UpdateInfo> {
})
}
}

#[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"));
}
}
93 changes: 93 additions & 0 deletions resources/WindowsUpdate/tests/windowsupdate_whatif.tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading