diff --git a/pester/winoneshot-compat.Tests.ps1 b/pester/winoneshot-compat.Tests.ps1 new file mode 100644 index 00000000..b7486225 --- /dev/null +++ b/pester/winoneshot-compat.Tests.ps1 @@ -0,0 +1,353 @@ +#=========================================================================== +# Tests - WinOneShot Catalog Compatibility +#=========================================================================== +# WinOneShot downloads these files from WinUtil's main/config raw GitHub URLs. +# Keep the known field types aligned with WinOneShot Models.cs; extra fields are allowed. + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$catalogCases = @( + @{ + Name = "applications.json" + Path = Join-Path $repoRoot "config\applications.json" + ScalarFields = @{ + category = "String" + choco = "String" + content = "String" + description = "String" + foss = "Boolean" + link = "String" + winget = "String" + } + ArrayFields = @{} + } + @{ + Name = "appx.json" + Path = Join-Path $repoRoot "config\appx.json" + ScalarFields = @{ + Category = "String" + Content = "String" + Description = "String" + Panel = "String" + PackageId = "String" + StoreId = "String" + } + ArrayFields = @{} + } + @{ + Name = "feature.json" + Path = Join-Path $repoRoot "config\feature.json" + ScalarFields = @{ + Content = "String" + Description = "String" + category = "String" + panel = "String" + Order = "String" + link = "String" + Type = "String" + ButtonWidth = "String" + function = "String" + } + ArrayFields = @{ + feature = "StringArray" + InvokeScript = "StringArray" + } + } + @{ + Name = "tweaks.json" + Path = Join-Path $repoRoot "config\tweaks.json" + ScalarFields = @{ + Content = "String" + Description = "String" + category = "String" + panel = "String" + Order = "String" + Type = "String" + Checked = "String" + ButtonWidth = "String" + ComboItems = "String" + link = "String" + } + ArrayFields = @{ + registry = "RegistryArray" + service = "ServiceArray" + ScheduledTask = "ScheduledTaskArray" + appx = "StringArray" + InvokeScript = "StringArray" + UndoScript = "StringArray" + } + } +) +$invalidCatalogCases = @( + @{ + Name = "applications.json" + Json = '[{"App":{}}]' + ScalarFields = $catalogCases[0].ScalarFields + ArrayFields = $catalogCases[0].ArrayFields + ExpectedError = "must contain a JSON object" + } + @{ + Name = "applications.json" + Json = "{}" + ScalarFields = $catalogCases[0].ScalarFields + ArrayFields = $catalogCases[0].ArrayFields + ExpectedError = "does not contain any entries" + } + @{ + Name = "applications.json" + Json = '{"Broken":null}' + ScalarFields = $catalogCases[0].ScalarFields + ArrayFields = $catalogCases[0].ArrayFields + ExpectedError = "applications.json.Broken is null" + } + @{ + Name = "applications.json" + Json = '{"App":{"foss":"yes"}}' + ScalarFields = $catalogCases[0].ScalarFields + ArrayFields = $catalogCases[0].ArrayFields + ExpectedError = "foss must be a Boolean" + } + @{ + Name = "appx.json" + Json = '{"App":{"PackageId":[]}}' + ScalarFields = $catalogCases[1].ScalarFields + ArrayFields = $catalogCases[1].ArrayFields + ExpectedError = "PackageId must be a string or null" + } + @{ + Name = "feature.json" + Json = '{"Feature":{"feature":"Example-Feature"}}' + ScalarFields = $catalogCases[2].ScalarFields + ArrayFields = $catalogCases[2].ArrayFields + ExpectedError = "feature must be an array or null" + } + @{ + Name = "tweaks.json" + Json = '{"Tweak":{"registry":{}}}' + ScalarFields = $catalogCases[3].ScalarFields + ArrayFields = $catalogCases[3].ArrayFields + ExpectedError = "registry must be an array or null" + } +) + +BeforeAll { + function script:Get-WinOneShotJsonKind { + param($Value) + + if ($null -eq $Value) { + return "Null" + } + if ($Value -is [string]) { + return "String" + } + if ($Value -is [bool]) { + return "Boolean" + } + if ($Value -is [array]) { + return "Array" + } + if ($Value -is [pscustomobject]) { + return "Object" + } + + return $Value.GetType().Name + } + + function script:Get-WinOneShotProperty { + param( + [Parameter(Mandatory)] + [pscustomobject]$Object, + + [Parameter(Mandatory)] + [string]$Name + ) + + foreach ($property in $Object.PSObject.Properties) { + if ($property.Name -ieq $Name) { + $property + } + } + } + + function script:Get-WinOneShotTypeError { + param( + [Parameter(Mandatory)] + [string]$Location, + + $Value, + + [Parameter(Mandatory)] + [string]$ExpectedType + ) + + $actualKind = Get-WinOneShotJsonKind -Value $Value + + if ($ExpectedType -eq "String") { + if ($actualKind -notin @("String", "Null")) { + return "$Location must be a string or null; found $actualKind" + } + return + } + + if ($ExpectedType -eq "Boolean") { + if ($actualKind -ne "Boolean") { + return "$Location must be a Boolean; found $actualKind" + } + return + } + + if ($actualKind -eq "Null") { + return + } + if ($actualKind -ne "Array") { + return "$Location must be an array or null; found $actualKind" + } + + if ($ExpectedType -eq "StringArray") { + for ($index = 0; $index -lt $Value.Count; $index++) { + $elementKind = Get-WinOneShotJsonKind -Value $Value[$index] + if ($elementKind -notin @("String", "Null")) { + "$Location[$index] must be a string or null; found $elementKind" + } + } + return + } + + $nestedFields = switch ($ExpectedType) { + "RegistryArray" { + @("Path", "Name", "Value", "OriginalValue", "DefaultState", "Type") + } + "ServiceArray" { + @("Name", "StartupType", "OriginalType") + } + "ScheduledTaskArray" { + @("Name") + } + default { + throw "Unknown WinOneShot contract type: $ExpectedType" + } + } + + for ($index = 0; $index -lt $Value.Count; $index++) { + $element = $Value[$index] + $elementKind = Get-WinOneShotJsonKind -Value $element + if ($elementKind -eq "Null") { + continue + } + if ($elementKind -ne "Object") { + "$Location[$index] must be an object or null; found $elementKind" + continue + } + + foreach ($field in $nestedFields) { + foreach ($property in (Get-WinOneShotProperty -Object $element -Name $field)) { + $propertyKind = Get-WinOneShotJsonKind -Value $property.Value + if ($propertyKind -notin @("String", "Null")) { + "$Location[$index].$($property.Name) must be a string or null; found $propertyKind" + } + } + } + } + } + + function script:Get-WinOneShotCatalogError { + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [string]$Json, + + [Parameter(Mandatory)] + [hashtable]$ScalarFields, + + [Parameter(Mandatory)] + [hashtable]$ArrayFields + ) + + try { + $catalog = $Json | ConvertFrom-Json -NoEnumerate -ErrorAction Stop + } catch { + return "WinOneShot cannot parse ${Name}: $_" + } + + if ((Get-WinOneShotJsonKind -Value $catalog) -ne "Object") { + return "$Name must contain a JSON object." + } + + $entries = @($catalog.PSObject.Properties) + if ($entries.Count -eq 0) { + return "$Name does not contain any entries." + } + + foreach ($entry in $entries) { + $entryKind = Get-WinOneShotJsonKind -Value $entry.Value + if ($entryKind -eq "Null") { + "$Name.$($entry.Name) is null" + continue + } + if ($entryKind -ne "Object") { + "$Name.$($entry.Name) must be an object; found $entryKind" + continue + } + + foreach ($field in $ScalarFields.Keys) { + foreach ($property in (Get-WinOneShotProperty -Object $entry.Value -Name $field)) { + Get-WinOneShotTypeError -Location "$Name.$($entry.Name).$($property.Name)" -Value $property.Value -ExpectedType $ScalarFields[$field] + } + } + + foreach ($field in $ArrayFields.Keys) { + foreach ($property in (Get-WinOneShotProperty -Object $entry.Value -Name $field)) { + Get-WinOneShotTypeError -Location "$Name.$($entry.Name).$($property.Name)" -Value $property.Value -ExpectedType $ArrayFields[$field] + } + } + } + } +} + +Describe "WinOneShot catalog download contract" { + foreach ($catalogCase in $catalogCases) { + It "publishes at the exact raw-download path" -TestCases $catalogCase { + param([string]$Path) + + Test-Path -LiteralPath $Path -PathType Leaf | Should -BeTrue + $exactNameMatch = @( + Get-ChildItem -LiteralPath (Split-Path -Path $Path -Parent) -File | + Where-Object { $_.Name -ceq (Split-Path -Path $Path -Leaf) } + ) + $exactNameMatch.Count | Should -Be 1 + } + + It "loads with WinOneShot-compatible field types" -TestCases $catalogCase { + param( + [string]$Name, + [string]$Path, + [hashtable]$ScalarFields, + [hashtable]$ArrayFields + ) + + $json = Get-Content -LiteralPath $Path -Raw + $errors = @(Get-WinOneShotCatalogError -Name $Name -Json $json -ScalarFields $ScalarFields -ArrayFields $ArrayFields) + + if ($errors.Count -gt 0) { + throw ($errors -join "`n") + } + } + } +} + +Describe "WinOneShot catalog contract guard" { + It "rejects consumer-breaking fixture: " -TestCases $invalidCatalogCases { + param( + [string]$Name, + [string]$Json, + [hashtable]$ScalarFields, + [hashtable]$ArrayFields, + [string]$ExpectedError + ) + + $errors = @(Get-WinOneShotCatalogError -Name $Name -Json $Json -ScalarFields $ScalarFields -ArrayFields $ArrayFields) + + $errors -join "`n" | Should -BeLike "*$ExpectedError*" + } +}