From d970454c0abb840f6fbf00647cc77adbbeeaf5eb Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:07:33 +0200 Subject: [PATCH 1/2] Add `[ResourceBase]` static methods for Microsoft DSC --- source/Classes/010.ResourceBase.ps1 | 203 +++++ .../ConvertTo-DscResourceJsonSchema.ps1 | 107 +++ .../ConvertTo-JsonSchemaTypeDefinition.ps1 | 100 +++ source/Private/New-DscResultTuple.ps1 | 67 ++ source/en-US/DscResource.Base.strings.psd1 | 1 + source/en-US/ResourceBase.strings.psd1 | 4 + .../DscResourceBaseTestResource.psd1 | 31 + .../DscResourceBaseTestResource.psm1 | 150 ++++ .../en-US/DscBaseTestResource.strings.psd1 | 9 + .../ResourceBase.Integration.Tests.ps1 | 201 ++++- tests/Unit/Classes/ResourceBase.Tests.ps1 | 827 ++++++++++++++++++ .../ConvertTo-DscResourceJsonSchema.Tests.ps1 | 235 +++++ ...nvertTo-JsonSchemaTypeDefinition.Tests.ps1 | 130 +++ .../Unit/Private/New-DscResultTuple.Tests.ps1 | 113 +++ 14 files changed, 2177 insertions(+), 1 deletion(-) create mode 100644 source/Private/ConvertTo-DscResourceJsonSchema.ps1 create mode 100644 source/Private/ConvertTo-JsonSchemaTypeDefinition.ps1 create mode 100644 source/Private/New-DscResultTuple.ps1 create mode 100644 tests/Integration/Fixtures/DscResourceBaseTestResource/DscResourceBaseTestResource.psd1 create mode 100644 tests/Integration/Fixtures/DscResourceBaseTestResource/DscResourceBaseTestResource.psm1 create mode 100644 tests/Integration/Fixtures/DscResourceBaseTestResource/en-US/DscBaseTestResource.strings.psd1 create mode 100644 tests/Unit/Private/ConvertTo-DscResourceJsonSchema.Tests.ps1 create mode 100644 tests/Unit/Private/ConvertTo-JsonSchemaTypeDefinition.Tests.ps1 create mode 100644 tests/Unit/Private/New-DscResultTuple.Tests.ps1 diff --git a/source/Classes/010.ResourceBase.ps1 b/source/Classes/010.ResourceBase.ps1 index af9e2b3..c710aa7 100644 --- a/source/Classes/010.ResourceBase.ps1 +++ b/source/Classes/010.ResourceBase.ps1 @@ -123,6 +123,17 @@ class ResourceBase } } + <# + Evaluate if we should set the canonical DSC property _exist. A key + property added to the current state means the object was assumed to + not exist in the current state. + #> + if (($this | Test-DscProperty -Name '_exist') -and -not $getCurrentStateResult.ContainsKey('_exist')) + { + $dscResourceObject._exist = -not $keyPropertyAddedToCurrentState + $getCurrentStateResult._exist = -not $keyPropertyAddedToCurrentState + } + <# Returns all enforced properties not in desired state, or $null if all enforced properties are in desired state. @@ -188,6 +199,198 @@ class ResourceBase return $true } + <# + Returns the DSC test result for the resource as a strongly typed tuple of + type [System.Tuple[System.Boolean, , System.String[]]] where: + + Item1 - $true if the resource is in the desired state, otherwise $false. + Item2 - An instance of the derived class representing the actual state. + Item3 - The names of the properties that are not in the desired state. + + This method should normally not be overridden. It is meant to be called + by the derived class static method Test([] $instance) that + participates in the semantics of Microsoft DSC. + #> + hidden [System.Object] GetTestResult() + { + $actualState = $this.Get() + + # $this.PropertiesNotInDesiredState was set by the Get() method. + $inDesiredState = -not $this.PropertiesNotInDesiredState + + [System.String[]] $differingProperties = @() + + if (-not $inDesiredState) + { + $differingProperties = [System.String[]] @($this.PropertiesNotInDesiredState.Property) + } + + return (New-DscResultTuple -Type @([System.Boolean], $this.GetType(), [System.String[]]) -Value @($inDesiredState, $actualState, $differingProperties)) + } + + <# + Enforces the desired state and returns the DSC set result as a strongly + typed tuple of type [System.Tuple[, System.String[]]] where: + + Item1 - An instance of the derived class representing the state after the + set operation, or the predicted state in what-if mode. + Item2 - The names of the properties that were (or would be) changed. + + This method should normally not be overridden. It is meant to be called + by the derived class static methods Set([] $instance) and + Set([] $instance, [System.Boolean] $whatIf) that participate + in the semantics of Microsoft DSC. + #> + hidden [System.Object] GetSetResult() + { + return $this.GetSetResult($false) + } + + hidden [System.Object] GetSetResult([System.Boolean] $WhatIf) + { + Write-Debug -Message ($this.localizedData.SetDesiredState -f $this.GetType().Name) + + $currentState = $this.Get() + + # $this.PropertiesNotInDesiredState was set by the Get() method. + if (-not $this.PropertiesNotInDesiredState) + { + Write-Debug -Message $this.localizedData.NoPropertiesToSet + + return (New-DscResultTuple -Type @($this.GetType(), [System.String[]]) -Value @($currentState, [System.String[]] @())) + } + + [System.String[]] $changedProperties = [System.String[]] @($this.PropertiesNotInDesiredState.Property) + + if ($WhatIf) + { + Write-Verbose -Message ($this.localizedData.WhatIfDesiredState -f $this.GetType().Name) + + $afterState = $this.GetPredictedState($currentState) + } + else + { + $propertiesToModify = $this.PropertiesNotInDesiredState | ConvertFrom-CompareResult + + foreach ($property in $propertiesToModify.Keys) + { + Write-Verbose -Message ($this.localizedData.SetProperty -f $property, $propertiesToModify.$property) + } + + <# + Call the Modify() method with the properties that should be enforced + and are not in desired state. + #> + $this.Modify($propertiesToModify) + + # Get the authoritative state after the modification. + $afterState = $this.Get() + } + + return (New-DscResultTuple -Type @($this.GetType(), [System.String[]]) -Value @($afterState, $changedProperties)) + } + + <# + Returns a new instance of the derived class representing the predicted + state after a set operation, without modifying the system. The predicted + state is the current state with the expected value applied to each + property that is not in the desired state. + + This method should normally not be overridden. + #> + hidden [ResourceBase] GetPredictedState([ResourceBase] $currentState) + { + $predictedState = [System.Activator]::CreateInstance($this.GetType()) + + # Copy the DSC properties from the current state. + $currentStateProperties = $currentState | Get-DscProperty + + foreach ($propertyName in @($currentStateProperties.Keys)) + { + if ($null -ne $currentStateProperties.$propertyName) + { + $predictedState.$propertyName = $currentStateProperties.$propertyName + } + } + + # Apply the desired value for each property that is not in the desired state. + foreach ($property in $this.PropertiesNotInDesiredState) + { + $predictedState.($property.Property) = $property.ExpectedValue + } + + # The predicted state is by definition in the desired state. + if ($predictedState | Test-DscProperty -Name 'Reasons') + { + $predictedState.Reasons = @() + } + + return $predictedState + } + + <# + Deletes the resource instance from the system. The default implementation + requires the resource to have the canonical DSC property _exist, or the + property Ensure as a fallback, and enforces the desired state with _exist + set to $false (or Ensure set to Absent). Resources without either property + must override this method to support the Microsoft DSC delete operation. + + This method is meant to be called by the derived class static method + Delete([] $instance) that participates in the semantics + of Microsoft DSC. + #> + hidden [void] DeleteInstance() + { + if ($this | Test-DscProperty -Name '_exist') + { + Write-Verbose -Message ($this.localizedData.DeleteInstance -f $this.GetType().Name) + + $this._exist = $false + } + elseif ($this | Test-DscProperty -Name 'Ensure') + { + Write-Verbose -Message ($this.localizedData.DeleteInstance -f $this.GetType().Name) + + $this.Ensure = [Ensure]::Absent + } + else + { + throw ($this.localizedData.DeleteInstanceNotSupported -f $this.GetType().Name) + } + + $this.Set() + } + + <# + This method can be overridden by a resource to support the Microsoft DSC export + operation. It must return every instance of the resource on the system, or, + when the parameter filteringInstance is not $null, only the matching + instances. The override must use the exact same method signature; the + returned array can hold instances of the derived class. + + This method is meant to be called by the derived class static methods + Export() and Export([] $filteringInstance) that participate + in the semantics of Microsoft DSC. + #> + hidden [ResourceBase[]] ExportInstances([ResourceBase] $filteringInstance) + { + throw ($this.localizedData.ExportInstancesMethodNotImplemented -f $this.GetType().Name) + } + + <# + Returns the JSON schema for an instance of the derived resource class, + built at runtime using reflection over the DSC properties. Reflection + sees properties inherited from base classes in other modules, which + build-time AST tooling cannot. + + This method is meant to be called by the derived class static method + InstanceJsonSchema() that participates in the semantics of Microsoft DSC. + #> + hidden [System.String] GetInstanceJsonSchema() + { + return (ConvertTo-DscResourceJsonSchema -ResourceType $this.GetType()) + } + <# Returns a hashtable containing all properties that should be enforced and are not in desired state, or $null if all enforced properties are in diff --git a/source/Private/ConvertTo-DscResourceJsonSchema.ps1 b/source/Private/ConvertTo-DscResourceJsonSchema.ps1 new file mode 100644 index 0000000..3f7c16e --- /dev/null +++ b/source/Private/ConvertTo-DscResourceJsonSchema.ps1 @@ -0,0 +1,107 @@ +<# + .SYNOPSIS + Converts a class-based DSC resource type to a JSON schema. + + .DESCRIPTION + Converts a class-based DSC resource type to a JSON schema that describes + an instance of the resource. The schema is built at runtime using + reflection over the properties that have the attribute DscProperty, + including properties inherited from base classes in other modules. + + .PARAMETER ResourceType + The type of the class-based DSC resource. + + .EXAMPLE + ConvertTo-DscResourceJsonSchema -ResourceType [MyResource] + + Returns a JSON string with the schema describing an instance of the + class-based DSC resource MyResource. + + .OUTPUTS + [System.String] +#> +function ConvertTo-DscResourceJsonSchema +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.Type] + $ResourceType + ) + + $schemaProperties = [ordered] @{} + $requiredList = [System.Collections.Generic.List[System.String]]::new() + + $bindingFlags = [System.Reflection.BindingFlags] 'Public, Instance' + + foreach ($property in $ResourceType.GetProperties($bindingFlags)) + { + $dscPropertyAttribute = $property.GetCustomAttributes([System.Management.Automation.DscPropertyAttribute], $true) | + Select-Object -First 1 + + if (-not $dscPropertyAttribute) + { + continue + } + + $schemaProperty = [ordered] @{} + + $validateSetAttribute = $property.GetCustomAttributes([System.Management.Automation.ValidateSetAttribute], $true) | + Select-Object -First 1 + + if ($validateSetAttribute) + { + $schemaProperty['type'] = 'string' + $schemaProperty['enum'] = [System.String[]] $validateSetAttribute.ValidValues + } + else + { + $jsonType = ConvertTo-JsonSchemaTypeDefinition -Type $property.PropertyType + + foreach ($key in $jsonType.Keys) + { + $schemaProperty[$key] = $jsonType[$key] + } + } + + $schemaProperty['title'] = $property.Name + + if (-not $schemaProperty.Contains('enum')) + { + $validatePatternAttribute = $property.GetCustomAttributes([System.Management.Automation.ValidatePatternAttribute], $true) | + Select-Object -First 1 + + if ($validatePatternAttribute) + { + $schemaProperty['pattern'] = $validatePatternAttribute.RegexPattern + } + } + + if ($dscPropertyAttribute.NotConfigurable) + { + $schemaProperty['readOnly'] = $true + } + + $schemaProperty['description'] = 'The {0} property.' -f $property.Name + + $schemaProperties[$property.Name] = $schemaProperty + + if ($dscPropertyAttribute.Key -or $dscPropertyAttribute.Mandatory) + { + $requiredList.Add($property.Name) + } + } + + $schema = [ordered] @{ + '$schema' = 'https://json-schema.org/draft/2020-12/schema' + title = $ResourceType.Name + type = 'object' + required = @($requiredList) + additionalProperties = $false + properties = $schemaProperties + } + + return ($schema | ConvertTo-Json -Depth 10) +} diff --git a/source/Private/ConvertTo-JsonSchemaTypeDefinition.ps1 b/source/Private/ConvertTo-JsonSchemaTypeDefinition.ps1 new file mode 100644 index 0000000..d03de4e --- /dev/null +++ b/source/Private/ConvertTo-JsonSchemaTypeDefinition.ps1 @@ -0,0 +1,100 @@ +<# + .SYNOPSIS + Converts a .NET type to its JSON schema type definition. + + .DESCRIPTION + Converts a .NET type to a hashtable describing the equivalent JSON + schema type. Nullable types are unwrapped, enum types are converted + to a string type with an enum keyword listing the enum names, and + array types are converted to an array type with an items keyword. + Unknown types fall back to the type string. + + .PARAMETER Type + The .NET type to convert. + + .EXAMPLE + ConvertTo-JsonSchemaTypeDefinition -Type ([System.Boolean]) + + Returns an ordered dictionary with the key type set to 'boolean'. + + .OUTPUTS + [System.Collections.Specialized.OrderedDictionary] +#> +function ConvertTo-JsonSchemaTypeDefinition +{ + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param + ( + [Parameter(Mandatory = $true)] + [System.Type] + $Type + ) + + $underlyingType = [System.Nullable]::GetUnderlyingType($Type) + + if ($underlyingType) + { + $Type = $underlyingType + } + + if ($Type.IsEnum) + { + return ([ordered] @{ + type = 'string' + enum = [System.String[]] ([System.Enum]::GetNames($Type)) + }) + } + + if ($Type.IsArray) + { + return ([ordered] @{ + type = 'array' + items = (ConvertTo-JsonSchemaTypeDefinition -Type $Type.GetElementType()) + }) + } + + $typeDefinition = switch ($Type.FullName) + { + 'System.String' + { + [ordered] @{ type = 'string' } + } + + 'System.Boolean' + { + [ordered] @{ type = 'boolean' } + } + + { $_ -in @('System.Byte', 'System.SByte', 'System.Int16', 'System.UInt16', 'System.Int32', 'System.UInt32', 'System.Int64', 'System.UInt64') } + { + [ordered] @{ type = 'integer' } + } + + { $_ -in @('System.Single', 'System.Double', 'System.Decimal') } + { + [ordered] @{ type = 'number' } + } + + 'System.DateTime' + { + [ordered] @{ + type = 'string' + format = 'date-time' + } + } + + 'System.Collections.Hashtable' + { + [ordered] @{ type = 'object' } + } + + default + { + # Default to string for unknown types. + [ordered] @{ type = 'string' } + } + } + + return $typeDefinition +} diff --git a/source/Private/New-DscResultTuple.ps1 b/source/Private/New-DscResultTuple.ps1 new file mode 100644 index 0000000..908b5cf --- /dev/null +++ b/source/Private/New-DscResultTuple.ps1 @@ -0,0 +1,67 @@ +<# + .SYNOPSIS + Creates a strongly typed tuple from the specified types and values. + + .DESCRIPTION + Creates a strongly typed tuple from the specified types and values. The + tuple is closed over the exact types that are passed in the parameter + Type, in the same order as the values passed in the parameter Value. + + The class ResourceBase uses this function to build the return values for + the DSC operation methods, for example the test result tuple + [System.Tuple[System.Boolean, , System.String[]]]. + + .PARAMETER Type + The types to close the tuple over, in element order. + + .PARAMETER Value + The values for each tuple element, in the same order as the parameter + Type. + + .EXAMPLE + New-DscResultTuple -Type @([System.Boolean], [System.String]) -Value @($true, 'MyValue') + + Returns a tuple of type [System.Tuple[System.Boolean, System.String]]. + + .OUTPUTS + [System.Object] + + .NOTES + Tuples are invariant classes, so a tuple closed over a base class type + does not convert to a declared return type that is closed over a derived + class type. The caller must always close the tuple over the runtime type + of the instance, e.g. $this.GetType(), so that a derived class static + method declared with the derived class type can return the tuple. + + The explicit MakeGenericType() call is used instead of + [System.Tuple]::Create() because generic type inference infers the type + System.Object for null arguments. +#> +function New-DscResultTuple +{ + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseShouldProcessForStateChangingFunctions', '', Justification = 'The function does not change state, it only creates and returns a tuple object.')] + [CmdletBinding()] + [OutputType([System.Object])] + param + ( + [Parameter(Mandatory = $true)] + [System.Type[]] + $Type, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [System.Object[]] + $Value + ) + + if ($Type.Count -ne $Value.Count) + { + throw ($script:localizedData.NewDscResultTuple_CountMismatch -f $Type.Count, $Value.Count) + } + + $openTupleType = [System.Type] ('System.Tuple`{0}' -f $Type.Count) + + $closedTupleType = $openTupleType.MakeGenericType($Type) + + return [System.Activator]::CreateInstance($closedTupleType, $Value) +} diff --git a/source/en-US/DscResource.Base.strings.psd1 b/source/en-US/DscResource.Base.strings.psd1 index f08354b..091d2c7 100644 --- a/source/en-US/DscResource.Base.strings.psd1 +++ b/source/en-US/DscResource.Base.strings.psd1 @@ -9,4 +9,5 @@ ConvertFrom-StringData @' DebugImportingLocalizationData = Importing localization data from '{0}' (DRB0001) ThrowClassIsNotPartOfModule = The class is not part of module DscResource.Base and no BaseDirectory was passed. Please provide BaseDirectory. (DRB0002) DebugShowAllLocalizationData = Localization data: '{0}' (DRB0003) + NewDscResultTuple_CountMismatch = The number of types ({0}) does not match the number of values ({1}). (DRB0004) '@ diff --git a/source/en-US/ResourceBase.strings.psd1 b/source/en-US/ResourceBase.strings.psd1 index 22dfb3e..d767e22 100644 --- a/source/en-US/ResourceBase.strings.psd1 +++ b/source/en-US/ResourceBase.strings.psd1 @@ -14,4 +14,8 @@ ConvertFrom-StringData @' NoPropertiesToSet = All properties are in desired state. (RB0007) ModifyMethodNotImplemented = An override for the method Modify() is not implemented in the resource. (RB0008) GetCurrentStateMethodNotImplemented = An override for the method GetCurrentState() is not implemented in the resource. (RB0009) + WhatIfDesiredState = Returning the predicted state for resource '{0}' (what-if mode). The system will not be modified. (RB0010) + DeleteInstanceNotSupported = The resource '{0}' does not support the delete operation because it does not have an 'Ensure' property. Override the method DeleteInstance() in the resource to support delete. (RB0011) + ExportInstancesMethodNotImplemented = An override for the method ExportInstances() is not implemented in the resource '{0}'. (RB0012) + DeleteInstance = Deleting the instance of the resource '{0}'. (RB0013) '@ diff --git a/tests/Integration/Fixtures/DscResourceBaseTestResource/DscResourceBaseTestResource.psd1 b/tests/Integration/Fixtures/DscResourceBaseTestResource/DscResourceBaseTestResource.psd1 new file mode 100644 index 0000000..b10b852 --- /dev/null +++ b/tests/Integration/Fixtures/DscResourceBaseTestResource/DscResourceBaseTestResource.psd1 @@ -0,0 +1,31 @@ +@{ + RootModule = 'DscResourceBaseTestResource.psm1' + ModuleVersion = '1.0.0' + GUID = 'f3a1c8d2-6b4e-4f9a-9c2d-8e7b5a0d1c3f' + Author = 'DSC Community' + CompanyName = 'DSC Community' + Copyright = 'Copyright the DSC Community contributors. All rights reserved.' + Description = 'Test fixture module with a class-based DSC resource that derives from ResourceBase in the module DscResource.Base. Used by the integration tests.' + PowerShellVersion = '5.0' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + DscResourcesToExport = @( + 'DscBaseTestResource' + ) + + PrivateData = @{ + PSData = @{ + # Capabilities fallback used by the DSC PowerShell adapter. + DscCapabilities = @( + 'get' + 'set' + 'test' + 'delete' + 'export' + ) + } + } +} diff --git a/tests/Integration/Fixtures/DscResourceBaseTestResource/DscResourceBaseTestResource.psm1 b/tests/Integration/Fixtures/DscResourceBaseTestResource/DscResourceBaseTestResource.psm1 new file mode 100644 index 0000000..d8f30e2 --- /dev/null +++ b/tests/Integration/Fixtures/DscResourceBaseTestResource/DscResourceBaseTestResource.psm1 @@ -0,0 +1,150 @@ +using module DscResource.Base + +<# + In-memory store representing the system state for the test resource. + Each key is the name of an instance and the value is the instance value. +#> +$script:testResourceCurrentState = @{ + 'Instance1' = 'Value1' + 'Instance2' = 'Value2' +} + +<# + .SYNOPSIS + A class-based DSC resource used by the integration tests. + + .DESCRIPTION + A class-based DSC resource that derives from ResourceBase in the module + DscResource.Base. The resource is backed by an in-memory store so the + integration tests never modify the system. + + .PARAMETER Name + The name of the instance. + + .PARAMETER Value + The value of the instance. + + .PARAMETER _exist + Canonical DSC property specifying whether the instance should exist. + + .PARAMETER Reasons + Returns the reason a property is not in the desired state. +#> +[DscResource()] +class DscBaseTestResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $Name + + [DscProperty()] + [System.String] + $Value + + [DscProperty()] + [System.Boolean] + $_exist = $true + + [DscProperty(NotConfigurable)] + [System.Collections.Hashtable[]] + $Reasons + + DscBaseTestResource () : base ($PSScriptRoot) + { + # The key property is returned by Get() but not enforced. + $this.ExcludeDscProperties = @( + 'Name' + ) + } + + #region PSDSC overrides + hidden [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + $currentState = @{} + + if ($script:testResourceCurrentState.ContainsKey($properties.Name)) + { + $currentState.Name = $properties.Name + $currentState.Value = $script:testResourceCurrentState[$properties.Name] + } + + return $currentState + } + + hidden [void] Modify([System.Collections.Hashtable] $properties) + { + if ($properties.ContainsKey('_exist') -and -not $properties.'_exist') + { + $script:testResourceCurrentState.Remove($this.Name) + + return + } + + $script:testResourceCurrentState[$this.Name] = $this.Value + } + + hidden [ResourceBase[]] ExportInstances([ResourceBase] $filteringInstance) + { + $instances = [System.Collections.Generic.List[ResourceBase]]::new() + + foreach ($instanceName in ($script:testResourceCurrentState.Keys | Sort-Object)) + { + if ($null -ne $filteringInstance -and -not [System.String]::IsNullOrEmpty($filteringInstance.Name) -and $instanceName -ne $filteringInstance.Name) + { + continue + } + + $instance = [DscBaseTestResource]::new() + $instance.Name = $instanceName + $instance.Value = $script:testResourceCurrentState[$instanceName] + $instance._exist = $true + + $instances.Add($instance) + } + + return $instances.ToArray() + } + #endregion PSDSC overrides + + #region DSC (v3) static methods + static [DscBaseTestResource] Get([DscBaseTestResource] $instance) + { + return $instance.Get() + } + + static [System.Tuple[System.Boolean, DscBaseTestResource, System.String[]]] Test([DscBaseTestResource] $instance) + { + return $instance.GetTestResult() + } + + static [System.Tuple[DscBaseTestResource, System.String[]]] Set([DscBaseTestResource] $instance) + { + return $instance.GetSetResult($false) + } + + static [System.Tuple[DscBaseTestResource, System.String[]]] Set([DscBaseTestResource] $instance, [System.Boolean] $whatIf) + { + return $instance.GetSetResult($whatIf) + } + + static [void] Delete([DscBaseTestResource] $instance) + { + $instance.DeleteInstance() + } + + static [DscBaseTestResource[]] Export() + { + return [DscBaseTestResource]::new().ExportInstances($null) + } + + static [DscBaseTestResource[]] Export([DscBaseTestResource] $filteringInstance) + { + return [DscBaseTestResource]::new().ExportInstances($filteringInstance) + } + + static [System.String] InstanceJsonSchema() + { + return [DscBaseTestResource]::new().GetInstanceJsonSchema() + } + #endregion DSC (v3) static methods +} diff --git a/tests/Integration/Fixtures/DscResourceBaseTestResource/en-US/DscBaseTestResource.strings.psd1 b/tests/Integration/Fixtures/DscResourceBaseTestResource/en-US/DscBaseTestResource.strings.psd1 new file mode 100644 index 0000000..57f10a0 --- /dev/null +++ b/tests/Integration/Fixtures/DscResourceBaseTestResource/en-US/DscBaseTestResource.strings.psd1 @@ -0,0 +1,9 @@ +<# + .SYNOPSIS + The localized resource strings in English (en-US) for the + class DscBaseTestResource. +#> + +ConvertFrom-StringData @' + TestResourceMessage = A message from the test resource '{0}'. (DBTR0001) +'@ diff --git a/tests/Integration/ResourceBase.Integration.Tests.ps1 b/tests/Integration/ResourceBase.Integration.Tests.ps1 index 6ad6f0e..3c46f4f 100644 --- a/tests/Integration/ResourceBase.Integration.Tests.ps1 +++ b/tests/Integration/ResourceBase.Integration.Tests.ps1 @@ -21,8 +21,207 @@ BeforeDiscovery { { throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' } + + $script:skipDscExe = -not [System.Boolean] (Get-Command -Name 'dsc' -CommandType 'Application' -ErrorAction 'SilentlyContinue') +} + +BeforeAll { + $script:originalPSModulePath = $env:PSModulePath + + # Make the fixture module and the built module discoverable, also for child processes (dsc.exe). + $script:fixturePath = Join-Path -Path $PSScriptRoot -ChildPath 'Fixtures' + $env:PSModulePath = '{0}{1}{2}' -f $script:fixturePath, [System.IO.Path]::PathSeparator, $env:PSModulePath + + Import-Module -Name 'DscResourceBaseTestResource' -Force -ErrorAction 'Stop' + + $script:fixtureModule = Get-Module -Name 'DscResourceBaseTestResource' + $script:resourceType = & $script:fixtureModule { [DscBaseTestResource] } +} + +AfterAll { + Get-Module -Name 'DscResourceBaseTestResource' -All | Remove-Module -Force + + $env:PSModulePath = $script:originalPSModulePath } Describe 'ResourceBase' { - # TODO: We must add integration tests here. + Context 'When using the Microsoft DSC static method Get()' { + It 'Should return the current state of an existing instance' { + $instance = $script:resourceType::new() + $instance.Name = 'Instance1' + + $getResult = $script:resourceType::Get($instance) + + $getResult.Name | Should -Be 'Instance1' + $getResult.Value | Should -Be 'Value1' + $getResult._exist | Should -BeTrue + } + + It 'Should return _exist as $false for an instance that does not exist' { + $instance = $script:resourceType::new() + $instance.Name = 'MissingInstance' + + $getResult = $script:resourceType::Get($instance) + + $getResult.Name | Should -Be 'MissingInstance' + $getResult._exist | Should -BeFalse + } + } + + Context 'When using the Microsoft DSC static method Test()' { + It 'Should return a tuple with $true when the instance is in the desired state' { + $instance = $script:resourceType::new() + $instance.Name = 'Instance1' + $instance.Value = 'Value1' + + $testResult = $script:resourceType::Test($instance) + + $testResult.Item1 | Should -BeTrue + $testResult.Item2.GetType().Name | Should -Be 'DscBaseTestResource' + $testResult.Item3 | Should -HaveCount 0 + } + + It 'Should return a tuple with the differing properties when the instance is not in the desired state' { + $instance = $script:resourceType::new() + $instance.Name = 'Instance1' + $instance.Value = 'NewValue1' + + $testResult = $script:resourceType::Test($instance) + + $testResult.Item1 | Should -BeFalse + $testResult.Item3 | Should -Contain 'Value' + } + } + + Context 'When using the Microsoft DSC static method Export()' { + It 'Should return every instance' { + $exportResult = $script:resourceType::Export() + + $exportResult | Should -HaveCount 2 + $exportResult[0].Name | Should -Be 'Instance1' + $exportResult[1].Name | Should -Be 'Instance2' + } + + It 'Should return only the matching instances when passing a filtering instance' { + $filteringInstance = $script:resourceType::new() + $filteringInstance.Name = 'Instance2' + + $exportResult = $script:resourceType::Export($filteringInstance) + + $exportResult | Should -HaveCount 1 + $exportResult[0].Name | Should -Be 'Instance2' + $exportResult[0].Value | Should -Be 'Value2' + } + } + + Context 'When using the Microsoft DSC static method Set()' { + It 'Should return the predicted state without modifying anything in what-if mode' { + $instance = $script:resourceType::new() + $instance.Name = 'Instance2' + $instance.Value = 'NewValue2' + + $setResult = $script:resourceType::Set($instance, $true) + + $setResult.Item1.Value | Should -Be 'NewValue2' + $setResult.Item2 | Should -Contain 'Value' + + # The system (in-memory store) must not have been modified. + $verifyInstance = $script:resourceType::new() + $verifyInstance.Name = 'Instance2' + + $script:resourceType::Get($verifyInstance).Value | Should -Be 'Value2' + } + + It 'Should enforce the desired state and return the state after the modification' { + $instance = $script:resourceType::new() + $instance.Name = 'Instance2' + $instance.Value = 'NewValue2' + + $setResult = $script:resourceType::Set($instance) + + $setResult.Item1.Value | Should -Be 'NewValue2' + $setResult.Item2 | Should -Contain 'Value' + + $verifyInstance = $script:resourceType::new() + $verifyInstance.Name = 'Instance2' + + $script:resourceType::Get($verifyInstance).Value | Should -Be 'NewValue2' + } + } + + Context 'When using the Microsoft DSC static method Delete()' { + It 'Should delete the instance' { + $instance = $script:resourceType::new() + $instance.Name = 'Instance1' + + $script:resourceType::Delete($instance) + + $verifyInstance = $script:resourceType::new() + $verifyInstance.Name = 'Instance1' + + $script:resourceType::Get($verifyInstance)._exist | Should -BeFalse + + $script:resourceType::Export() | Should -HaveCount 1 + } + } + + Context 'When using the Microsoft DSC static method InstanceJsonSchema()' { + It 'Should return a valid JSON schema for the resource' { + $schemaJson = $script:resourceType::InstanceJsonSchema() + + $schema = $schemaJson | ConvertFrom-Json -ErrorAction 'Stop' + + $schema.title | Should -Be 'DscBaseTestResource' + $schema.required | Should -Contain 'Name' + $schema.properties.Name.type | Should -Be 'string' + $schema.properties.Value.type | Should -Be 'string' + $schema.properties._exist.type | Should -Be 'boolean' + $schema.properties.Reasons.readOnly | Should -BeTrue + } + } + + Context 'When using the Microsoft DSC instance methods' { + It 'Should support Get(), Test() and Set()' { + $instance = $script:resourceType::new() + $instance.Name = 'Instance2' + $instance.Value = 'NewValue2' + + $instance.Test() | Should -BeTrue + + $getResult = $instance.Get() + + $getResult.Value | Should -Be 'NewValue2' + $getResult.Reasons | Should -HaveCount 0 + } + } +} + +Describe 'ResourceBase with dsc.exe' -Tag 'RequiresDsc' -Skip:$script:skipDscExe { + Context 'When invoking operations through the DSC PowerShell adapter' { + It 'Should return the current state for the get operation' { + $result = dsc resource get --resource 'DscResourceBaseTestResource/DscBaseTestResource' --input '{"Name":"Instance1"}' 2> $null | + ConvertFrom-Json + + $LASTEXITCODE | Should -Be 0 + $result.actualState.Name | Should -Be 'Instance1' + $result.actualState.Value | Should -Be 'Value1' + } + + It 'Should return inDesiredState as $false for the test operation with a differing value' { + $result = dsc resource test --resource 'DscResourceBaseTestResource/DscBaseTestResource' --input '{"Name":"Instance1","Value":"OtherValue"}' 2> $null | + ConvertFrom-Json + + $LASTEXITCODE | Should -Be 0 + $result.inDesiredState | Should -BeFalse + $result.differingProperties | Should -Contain 'Value' + } + + It 'Should return every instance for the export operation' { + $result = dsc resource export --resource 'DscResourceBaseTestResource/DscBaseTestResource' 2> $null | + ConvertFrom-Json + + $LASTEXITCODE | Should -Be 0 + $result.resources | Should -HaveCount 2 + } + } } diff --git a/tests/Unit/Classes/ResourceBase.Tests.ps1 b/tests/Unit/Classes/ResourceBase.Tests.ps1 index 969bf90..eeab21e 100644 --- a/tests/Unit/Classes/ResourceBase.Tests.ps1 +++ b/tests/Unit/Classes/ResourceBase.Tests.ps1 @@ -1435,3 +1435,830 @@ $script:mockResourceBaseInstance = [MyMockResource]::new() } } } + +Describe 'ResourceBase\GetTestResult()' -Tag 'GetTestResult' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + MyMockResource () {} + + static [System.Tuple[System.Boolean, MyMockResource, System.String[]]] Test([MyMockResource] $instance) + { + return $instance.GetTestResult() + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +$script:mockResourceBaseType = [MyMockResource] +'@ + } + + Context 'When the system is in the desired state' { + BeforeAll { + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + InModuleScope -ScriptBlock { + $script:getMethodCallCount = 0 + + $mockResourceBaseInstance | Add-Member -MemberType ScriptMethod -Name 'Get' -Value { + $script:getMethodCallCount++ + + $currentStateInstance = [System.Activator]::CreateInstance($this.GetType()) + $currentStateInstance.MyResourceKeyProperty1 = 'MyValue1' + $currentStateInstance.MyResourceProperty2 = 'MyValue2' + + return $currentStateInstance + } -Force + } + } + + It 'Should return a tuple closed over the derived class type' { + InModuleScope -ScriptBlock { + $testResult = $mockResourceBaseInstance.GetTestResult() + + $genericArguments = $testResult.GetType().GetGenericArguments() + + $genericArguments[0].Name | Should -Be 'Boolean' + $genericArguments[1].Name | Should -Be 'MyMockResource' + $genericArguments[2].Name | Should -Be 'String[]' + } + } + + It 'Should return the correct tuple values' { + InModuleScope -ScriptBlock { + $script:getMethodCallCount = 0 + + $testResult = $mockResourceBaseInstance.GetTestResult() + + $testResult.Item1 | Should -BeTrue + $testResult.Item2.MyResourceProperty2 | Should -Be 'MyValue2' + $testResult.Item3 | Should -HaveCount 0 + + $script:getMethodCallCount | Should -Be 1 + } + } + + It 'Should return the tuple through the derived class static method Test()' { + InModuleScope -ScriptBlock { + $testResult = $mockResourceBaseType::Test($mockResourceBaseInstance) + + $testResult.Item1 | Should -BeTrue + $testResult.Item2.GetType().Name | Should -Be 'MyMockResource' + $testResult.Item3 | Should -HaveCount 0 + } + } + } + + Context 'When the system is not in the desired state' { + BeforeAll { + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + InModuleScope -ScriptBlock { + $script:getMethodCallCount = 0 + + $mockResourceBaseInstance | Add-Member -MemberType ScriptMethod -Name 'Get' -Value { + $script:getMethodCallCount++ + + $currentStateInstance = [System.Activator]::CreateInstance($this.GetType()) + $currentStateInstance.MyResourceKeyProperty1 = 'MyValue1' + $currentStateInstance.MyResourceProperty2 = 'MyValue2' + + return $currentStateInstance + } -Force + + $mockResourceBaseInstance.PropertiesNotInDesiredState = @( + @{ + Property = 'MyResourceProperty2' + ExpectedValue = 'MyNewValue2' + ActualValue = 'MyValue2' + } + ) + } + } + + It 'Should return the correct tuple values' { + InModuleScope -ScriptBlock { + $testResult = $mockResourceBaseInstance.GetTestResult() + + $testResult.Item1 | Should -BeFalse + $testResult.Item3 | Should -HaveCount 1 + $testResult.Item3 | Should -Contain 'MyResourceProperty2' + + $script:getMethodCallCount | Should -Be 1 + } + } + + It 'Should return the tuple through the derived class static method Test()' { + InModuleScope -ScriptBlock { + $testResult = $mockResourceBaseType::Test($mockResourceBaseInstance) + + $testResult.Item1 | Should -BeFalse + $testResult.Item3 | Should -Contain 'MyResourceProperty2' + } + } + } +} + +Describe 'ResourceBase\GetSetResult()' -Tag 'GetSetResult' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + MyMockResource () {} + + static [System.Tuple[MyMockResource, System.String[]]] Set([MyMockResource] $instance, [System.Boolean] $whatIf) + { + return $instance.GetSetResult($whatIf) + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +$script:mockResourceBaseType = [MyMockResource] +'@ + } + + Context 'When the system is in the desired state' { + BeforeAll { + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + InModuleScope -ScriptBlock { + $script:getMethodCallCount = 0 + $script:modifyMethodCallCount = 0 + + $mockResourceBaseInstance | Add-Member -MemberType ScriptMethod -Name 'Get' -Value { + $script:getMethodCallCount++ + + $currentStateInstance = [System.Activator]::CreateInstance($this.GetType()) + $currentStateInstance.MyResourceKeyProperty1 = 'MyValue1' + $currentStateInstance.MyResourceProperty2 = 'MyValue2' + + return $currentStateInstance + } -Force -PassThru | + Add-Member -MemberType ScriptMethod -Name 'Modify' -Value { + $script:modifyMethodCallCount++ + } -Force + } + } + + It 'Should not modify anything and return the current state with no changed properties' { + InModuleScope -ScriptBlock { + $setResult = $mockResourceBaseInstance.GetSetResult() + + $setResult.Item1.MyResourceProperty2 | Should -Be 'MyValue2' + $setResult.Item2 | Should -HaveCount 0 + + $script:getMethodCallCount | Should -Be 1 + $script:modifyMethodCallCount | Should -Be 0 + } + } + + It 'Should return a tuple closed over the derived class type' { + InModuleScope -ScriptBlock { + $setResult = $mockResourceBaseInstance.GetSetResult() + + $genericArguments = $setResult.GetType().GetGenericArguments() + + $genericArguments[0].Name | Should -Be 'MyMockResource' + $genericArguments[1].Name | Should -Be 'String[]' + } + } + } + + Context 'When the system is not in the desired state' { + BeforeAll { + Mock -CommandName ConvertFrom-CompareResult -MockWith { + return @{ + MyResourceProperty2 = 'MyNewValue2' + } + } + } + + BeforeEach { + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + InModuleScope -ScriptBlock { + $script:getMethodCallCount = 0 + $script:modifyMethodCallCount = 0 + + $mockResourceBaseInstance | Add-Member -MemberType ScriptMethod -Name 'Get' -Value { + $script:getMethodCallCount++ + + $currentStateInstance = [System.Activator]::CreateInstance($this.GetType()) + $currentStateInstance.MyResourceKeyProperty1 = 'MyValue1' + $currentStateInstance.MyResourceProperty2 = 'MyValue2' + + return $currentStateInstance + } -Force -PassThru | + Add-Member -MemberType ScriptMethod -Name 'Modify' -Value { + $script:modifyMethodCallCount++ + } -Force + + $mockResourceBaseInstance.PropertiesNotInDesiredState = @( + @{ + Property = 'MyResourceProperty2' + ExpectedValue = 'MyNewValue2' + ActualValue = 'MyValue2' + } + ) + } + } + + It 'Should modify the properties and return the state after the modification' { + InModuleScope -ScriptBlock { + $setResult = $mockResourceBaseInstance.GetSetResult($false) + + $setResult.Item2 | Should -HaveCount 1 + $setResult.Item2 | Should -Contain 'MyResourceProperty2' + + $script:modifyMethodCallCount | Should -Be 1 + + # One call to get the current state and one call to get the state after the modification. + $script:getMethodCallCount | Should -Be 2 + } + } + + It 'Should not modify anything in what-if mode and return the predicted state' { + InModuleScope -ScriptBlock { + $setResult = $mockResourceBaseInstance.GetSetResult($true) + + $setResult.Item1.MyResourceProperty2 | Should -Be 'MyNewValue2' + $setResult.Item1.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $setResult.Item2 | Should -HaveCount 1 + $setResult.Item2 | Should -Contain 'MyResourceProperty2' + + $script:modifyMethodCallCount | Should -Be 0 + $script:getMethodCallCount | Should -Be 1 + } + } + + It 'Should return the tuple through the derived class static method Set()' { + InModuleScope -ScriptBlock { + $setResult = $mockResourceBaseType::Set($mockResourceBaseInstance, $true) + + $setResult.Item1.GetType().Name | Should -Be 'MyMockResource' + $setResult.Item2 | Should -Contain 'MyResourceProperty2' + + $script:modifyMethodCallCount | Should -Be 0 + } + } + } +} + +Describe 'ResourceBase\GetPredictedState()' -Tag 'GetPredictedState' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [System.Collections.Hashtable[]] + $Reasons + + MyMockResource () {} +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.PropertiesNotInDesiredState = @( + @{ + Property = 'MyResourceProperty2' + ExpectedValue = 'MyNewValue2' + ActualValue = 'MyValue2' + } + ) + + $script:mockCurrentStateInstance = [System.Activator]::CreateInstance($mockResourceBaseInstance.GetType()) + $script:mockCurrentStateInstance.MyResourceKeyProperty1 = 'MyValue1' + $script:mockCurrentStateInstance.MyResourceProperty2 = 'MyValue2' + $script:mockCurrentStateInstance.Reasons = @( + @{ + Code = 'MyMockResource:MyMockResource:MyResourceProperty2' + Phrase = 'The property MyResourceProperty2 should be "MyNewValue2", but was "MyValue2"' + } + ) + } + } + + It 'Should return the current state with the expected values applied' { + InModuleScope -ScriptBlock { + $predictedState = $mockResourceBaseInstance.GetPredictedState($mockCurrentStateInstance) + + $predictedState.GetType().Name | Should -Be 'MyMockResource' + $predictedState.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $predictedState.MyResourceProperty2 | Should -Be 'MyNewValue2' + } + } + + It 'Should return an empty Reasons property' { + InModuleScope -ScriptBlock { + $predictedState = $mockResourceBaseInstance.GetPredictedState($mockCurrentStateInstance) + + $predictedState.Reasons | Should -HaveCount 0 + } + } + + It 'Should not modify the passed current state instance' { + InModuleScope -ScriptBlock { + $null = $mockResourceBaseInstance.GetPredictedState($mockCurrentStateInstance) + + $mockCurrentStateInstance.MyResourceProperty2 | Should -Be 'MyValue2' + } + } +} + +Describe 'ResourceBase\DeleteInstance()' -Tag 'DeleteInstance' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + } + + Context 'When the resource has the canonical DSC property _exist' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.Boolean] + $_exist = $true + + MyMockResource () {} +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + InModuleScope -ScriptBlock { + $script:setMethodCallCount = 0 + + $mockResourceBaseInstance | Add-Member -MemberType ScriptMethod -Name 'Set' -Value { + $script:setMethodCallCount++ + } -Force + } + } + + It 'Should set _exist to $false and enforce the desired state' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.DeleteInstance() + + $mockResourceBaseInstance._exist | Should -BeFalse + $script:setMethodCallCount | Should -Be 1 + } + } + } + + Context 'When the resource has the property Ensure' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + MyMockResource () {} +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + InModuleScope -ScriptBlock { + $script:setMethodCallCount = 0 + + $mockResourceBaseInstance | Add-Member -MemberType ScriptMethod -Name 'Set' -Value { + $script:setMethodCallCount++ + } -Force + } + } + + It 'Should set Ensure to Absent and enforce the desired state' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.DeleteInstance() + + $mockResourceBaseInstance.Ensure | Should -Be ([Ensure]::Absent) + $script:setMethodCallCount | Should -Be 1 + } + } + } + + Context 'When the resource has both the property _exist and the property Ensure' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.Boolean] + $_exist = $true + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + MyMockResource () {} +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + InModuleScope -ScriptBlock { + $script:setMethodCallCount = 0 + + $mockResourceBaseInstance | Add-Member -MemberType ScriptMethod -Name 'Set' -Value { + $script:setMethodCallCount++ + } -Force + } + } + + It 'Should use the canonical DSC property _exist' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.DeleteInstance() + + $mockResourceBaseInstance._exist | Should -BeFalse + $mockResourceBaseInstance.Ensure | Should -Be ([Ensure]::Present) + $script:setMethodCallCount | Should -Be 1 + } + } + } + + Context 'When the resource has neither the property _exist nor the property Ensure' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + MyMockResource () {} +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should throw the correct error' { + InModuleScope -ScriptBlock { + { $mockResourceBaseInstance.DeleteInstance() } | Should -Throw -ExpectedMessage '*does not support the delete operation*' + } + } + } +} + +Describe 'ResourceBase\ExportInstances()' -Tag 'ExportInstances' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + } + + Context 'When the method ExportInstances() is not overridden' { + BeforeAll { + $mockResourceBaseInstance = InModuleScope -ScriptBlock { + [ResourceBase]::new() + } + } + + It 'Should throw the correct error' { + { $mockResourceBaseInstance.ExportInstances($null) } | Should -Throw -ExpectedMessage '*ExportInstances()*' + } + } + + Context 'When the method ExportInstances() is overridden' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + MyMockResource () {} + + hidden [ResourceBase[]] ExportInstances([ResourceBase] $filteringInstance) + { + $instance1 = [MyMockResource]::new() + $instance1.MyResourceKeyProperty1 = 'Instance1' + + $instance2 = [MyMockResource]::new() + $instance2.MyResourceKeyProperty1 = 'Instance2' + + if ($null -ne $filteringInstance) + { + return @($instance1) + } + + return @($instance1, $instance2) + } + + static [MyMockResource[]] Export() + { + return [MyMockResource]::new().ExportInstances($null) + } + + static [MyMockResource[]] Export([MyMockResource] $filteringInstance) + { + return [MyMockResource]::new().ExportInstances($filteringInstance) + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +$script:mockResourceBaseType = [MyMockResource] +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should return every instance through the derived class static method Export()' { + InModuleScope -ScriptBlock { + $exportResult = $mockResourceBaseType::Export() + + $exportResult | Should -HaveCount 2 + $exportResult[0].GetType().Name | Should -Be 'MyMockResource' + $exportResult[0].MyResourceKeyProperty1 | Should -Be 'Instance1' + $exportResult[1].MyResourceKeyProperty1 | Should -Be 'Instance2' + } + } + + It 'Should return the matching instances through the derived class static method Export() with a filtering instance' { + InModuleScope -ScriptBlock { + $exportResult = $mockResourceBaseType::Export($mockResourceBaseInstance) + + $exportResult | Should -HaveCount 1 + $exportResult[0].MyResourceKeyProperty1 | Should -Be 'Instance1' + } + } + } +} + +Describe 'ResourceBase\GetInstanceJsonSchema()' -Tag 'GetInstanceJsonSchema' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockIntermediateResource : ResourceBase +{ + [DscProperty()] + [System.String] + $MyInheritedProperty +} + +class MyMockResource : MyMockIntermediateResource +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.Boolean] + $_exist = $true + + [DscProperty(NotConfigurable)] + [System.Collections.Hashtable[]] + $Reasons + + MyMockResource () {} + + static [System.String] InstanceJsonSchema() + { + return [MyMockResource]::new().GetInstanceJsonSchema() + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +$script:mockResourceBaseType = [MyMockResource] +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should return a valid JSON string through the derived class static method InstanceJsonSchema()' { + InModuleScope -ScriptBlock { + $schemaJson = $mockResourceBaseType::InstanceJsonSchema() + + { $schemaJson | ConvertFrom-Json -ErrorAction 'Stop' } | Should -Not -Throw + } + } + + It 'Should return the correct schema' { + InModuleScope -ScriptBlock { + $schema = $mockResourceBaseType::InstanceJsonSchema() | ConvertFrom-Json + + $schema.title | Should -Be 'MyMockResource' + $schema.type | Should -Be 'object' + $schema.required | Should -Contain 'MyResourceKeyProperty1' + $schema.additionalProperties | Should -BeFalse + + $schema.properties.MyResourceKeyProperty1.type | Should -Be 'string' + $schema.properties.Ensure.type | Should -Be 'string' + $schema.properties.Ensure.enum | Should -Contain 'Present' + $schema.properties.Ensure.enum | Should -Contain 'Absent' + $schema.properties._exist.type | Should -Be 'boolean' + $schema.properties.Reasons.type | Should -Be 'array' + $schema.properties.Reasons.readOnly | Should -BeTrue + } + } + + It 'Should include properties inherited from a base class' { + InModuleScope -ScriptBlock { + $schema = $mockResourceBaseType::InstanceJsonSchema() | ConvertFrom-Json + + $schema.properties.MyInheritedProperty.type | Should -Be 'string' + } + } +} + +Describe 'ResourceBase\Get() canonical DSC property _exist' -Tag 'Get' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + } + + Context 'When the object exists in the current state' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.Boolean] + $_exist = $true + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + MyMockResource() : base () + { + # These properties will not be enforced. + $this.ExcludeDscProperties = @( + 'MyResourceKeyProperty1' + ) + } + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + return @{ + MyResourceKeyProperty1 = 'MyValue1' + MyResourceProperty2 = 'MyValue2' + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should return _exist as $true' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + $mockResourceBaseInstance.MyResourceProperty2 = 'MyValue2' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult._exist | Should -BeTrue + } + } + } + + Context 'When the object does not exist in the current state' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.Boolean] + $_exist = $true + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + MyMockResource() : base () + { + # These properties will not be enforced. + $this.ExcludeDscProperties = @( + 'MyResourceKeyProperty1' + ) + } + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + # The object does not exist in the current state. + return @{} + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should return _exist as $false' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult._exist | Should -BeFalse + } + } + } +} diff --git a/tests/Unit/Private/ConvertTo-DscResourceJsonSchema.Tests.ps1 b/tests/Unit/Private/ConvertTo-DscResourceJsonSchema.Tests.ps1 new file mode 100644 index 0000000..7cfe5b4 --- /dev/null +++ b/tests/Unit/Private/ConvertTo-DscResourceJsonSchema.Tests.ps1 @@ -0,0 +1,235 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'DscResource.Base' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-DscResourceJsonSchema' -Tag 'Private' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockIntermediateResource : ResourceBase +{ + [DscProperty()] + [System.String] + $MyInheritedProperty +} + +class MyMockResource : MyMockIntermediateResource +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty(Mandatory)] + [System.String] + $MyMandatoryProperty + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.Boolean] + $_exist = $true + + [DscProperty()] + [ValidateSet('Value1', 'Value2')] + [System.String] + $MyValidateSetProperty + + [DscProperty()] + [ValidatePattern('^[a-z]+$')] + [System.String] + $MyValidatePatternProperty + + [DscProperty()] + [System.String[]] + $MyArrayProperty + + [DscProperty()] + [Nullable[System.Int32]] + $MyNullableProperty + + [DscProperty(NotConfigurable)] + [System.Collections.Hashtable[]] + $Reasons + + # This property must not be part of the schema. + [System.String] + $MyNonDscProperty + + MyMockResource () {} +} + +$script:mockResourceBaseType = [MyMockResource] +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should return a valid JSON string' { + InModuleScope -ScriptBlock { + $schemaJson = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType + + { $schemaJson | ConvertFrom-Json -ErrorAction 'Stop' } | Should -Not -Throw + } + } + + It 'Should return the correct schema document keywords' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.'$schema' | Should -Be 'https://json-schema.org/draft/2020-12/schema' + $schema.title | Should -Be 'MyMockResource' + $schema.type | Should -Be 'object' + $schema.additionalProperties | Should -BeFalse + } + } + + It 'Should add key and mandatory properties to the required keyword' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.required | Should -Contain 'MyResourceKeyProperty1' + $schema.required | Should -Contain 'MyMandatoryProperty' + $schema.required | Should -Not -Contain 'Ensure' + } + } + + It 'Should convert an enum property to a string with an enum keyword' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties.Ensure.type | Should -Be 'string' + $schema.properties.Ensure.enum | Should -Contain 'Present' + $schema.properties.Ensure.enum | Should -Contain 'Absent' + } + } + + It 'Should convert the canonical DSC property _exist to a boolean' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties._exist.type | Should -Be 'boolean' + } + } + + It 'Should convert a property with ValidateSet to a string with an enum keyword' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties.MyValidateSetProperty.type | Should -Be 'string' + $schema.properties.MyValidateSetProperty.enum | Should -Contain 'Value1' + $schema.properties.MyValidateSetProperty.enum | Should -Contain 'Value2' + } + } + + It 'Should convert a property with ValidatePattern to a string with a pattern keyword' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties.MyValidatePatternProperty.type | Should -Be 'string' + $schema.properties.MyValidatePatternProperty.pattern | Should -Be '^[a-z]+$' + } + } + + It 'Should convert an array property to an array with an items keyword' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties.MyArrayProperty.type | Should -Be 'array' + $schema.properties.MyArrayProperty.items.type | Should -Be 'string' + } + } + + It 'Should convert a nullable property to the underlying type' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties.MyNullableProperty.type | Should -Be 'integer' + } + } + + It 'Should convert a not configurable property to a read-only property' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties.Reasons.type | Should -Be 'array' + $schema.properties.Reasons.readOnly | Should -BeTrue + } + } + + It 'Should include properties inherited from a base class' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties.MyInheritedProperty.type | Should -Be 'string' + } + } + + It 'Should not include properties without the DscProperty attribute' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties.PSObject.Properties.Name | Should -Not -Contain 'MyNonDscProperty' + } + } + + It 'Should set a default description for each property' { + InModuleScope -ScriptBlock { + $schema = ConvertTo-DscResourceJsonSchema -ResourceType $mockResourceBaseType | ConvertFrom-Json + + $schema.properties.MyResourceKeyProperty1.description | Should -Be 'The MyResourceKeyProperty1 property.' + } + } +} diff --git a/tests/Unit/Private/ConvertTo-JsonSchemaTypeDefinition.Tests.ps1 b/tests/Unit/Private/ConvertTo-JsonSchemaTypeDefinition.Tests.ps1 new file mode 100644 index 0000000..fa35b42 --- /dev/null +++ b/tests/Unit/Private/ConvertTo-JsonSchemaTypeDefinition.Tests.ps1 @@ -0,0 +1,130 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'DscResource.Base' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-JsonSchemaTypeDefinition' -Tag 'Private' { + Context 'When converting simple types' { + It 'Should convert the type to the JSON schema type ' -ForEach @( + @{ TypeName = [System.String]; ExpectedType = 'string' } + @{ TypeName = [System.Boolean]; ExpectedType = 'boolean' } + @{ TypeName = [System.Byte]; ExpectedType = 'integer' } + @{ TypeName = [System.Int16]; ExpectedType = 'integer' } + @{ TypeName = [System.Int32]; ExpectedType = 'integer' } + @{ TypeName = [System.Int64]; ExpectedType = 'integer' } + @{ TypeName = [System.UInt32]; ExpectedType = 'integer' } + @{ TypeName = [System.Single]; ExpectedType = 'number' } + @{ TypeName = [System.Double]; ExpectedType = 'number' } + @{ TypeName = [System.Decimal]; ExpectedType = 'number' } + @{ TypeName = [System.Collections.Hashtable]; ExpectedType = 'object' } + ) { + InModuleScope -Parameters $_ -ScriptBlock { + $result = ConvertTo-JsonSchemaTypeDefinition -Type $TypeName + + $result.type | Should -Be $ExpectedType + } + } + } + + Context 'When converting the type DateTime' { + It 'Should convert to a string with the format date-time' { + InModuleScope -ScriptBlock { + $result = ConvertTo-JsonSchemaTypeDefinition -Type ([System.DateTime]) + + $result.type | Should -Be 'string' + $result.format | Should -Be 'date-time' + } + } + } + + Context 'When converting an enum type' { + It 'Should convert to a string with an enum keyword' { + InModuleScope -ScriptBlock { + $result = ConvertTo-JsonSchemaTypeDefinition -Type ([Ensure]) + + $result.type | Should -Be 'string' + $result.enum | Should -Contain 'Present' + $result.enum | Should -Contain 'Absent' + } + } + } + + Context 'When converting an array type' { + It 'Should convert to an array with an items keyword' { + InModuleScope -ScriptBlock { + $result = ConvertTo-JsonSchemaTypeDefinition -Type ([System.String[]]) + + $result.type | Should -Be 'array' + $result.items.type | Should -Be 'string' + } + } + + It 'Should convert the element type of the array' { + InModuleScope -ScriptBlock { + $result = ConvertTo-JsonSchemaTypeDefinition -Type ([System.Collections.Hashtable[]]) + + $result.type | Should -Be 'array' + $result.items.type | Should -Be 'object' + } + } + } + + Context 'When converting a nullable type' { + It 'Should convert to the underlying type' { + InModuleScope -ScriptBlock { + $result = ConvertTo-JsonSchemaTypeDefinition -Type ([System.Nullable[System.Int32]]) + + $result.type | Should -Be 'integer' + } + } + } + + Context 'When converting an unknown type' { + It 'Should fall back to the type string' { + InModuleScope -ScriptBlock { + $result = ConvertTo-JsonSchemaTypeDefinition -Type ([System.Guid]) + + $result.type | Should -Be 'string' + } + } + } +} diff --git a/tests/Unit/Private/New-DscResultTuple.Tests.ps1 b/tests/Unit/Private/New-DscResultTuple.Tests.ps1 new file mode 100644 index 0000000..3dfc5de --- /dev/null +++ b/tests/Unit/Private/New-DscResultTuple.Tests.ps1 @@ -0,0 +1,113 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'DscResource.Base' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'New-DscResultTuple' -Tag 'Private' { + Context 'When creating a tuple with two elements' { + It 'Should return a tuple closed over the correct types' { + InModuleScope -ScriptBlock { + $result = New-DscResultTuple -Type @([System.String], [System.String[]]) -Value @('MyValue', [System.String[]] @('MyProperty')) + + $genericArguments = $result.GetType().GetGenericArguments() + + $genericArguments | Should -HaveCount 2 + $genericArguments[0].Name | Should -Be 'String' + $genericArguments[1].Name | Should -Be 'String[]' + + $result.Item1 | Should -Be 'MyValue' + $result.Item2 | Should -Contain 'MyProperty' + } + } + } + + Context 'When creating a tuple with three elements' { + It 'Should return a tuple closed over the correct types' { + InModuleScope -ScriptBlock { + $result = New-DscResultTuple -Type @([System.Boolean], [System.String], [System.String[]]) -Value @($true, 'MyValue', [System.String[]] @()) + + $genericArguments = $result.GetType().GetGenericArguments() + + $genericArguments | Should -HaveCount 3 + $genericArguments[0].Name | Should -Be 'Boolean' + $genericArguments[1].Name | Should -Be 'String' + $genericArguments[2].Name | Should -Be 'String[]' + + $result.Item1 | Should -BeTrue + $result.Item3 | Should -HaveCount 0 + } + } + } + + Context 'When creating a tuple closed over a class type' { + It 'Should return a tuple closed over the class type' { + InModuleScope -ScriptBlock { + $instance = [ResourceBase]::new() + + $result = New-DscResultTuple -Type @([System.Boolean], $instance.GetType(), [System.String[]]) -Value @($false, $instance, [System.String[]] @('MyProperty')) + + $result.GetType().GetGenericArguments()[1].Name | Should -Be 'ResourceBase' + $result.Item2 | Should -Be $instance + } + } + } + + Context 'When an element value is null' { + It 'Should return a tuple with the null element' { + InModuleScope -ScriptBlock { + $result = New-DscResultTuple -Type @([System.String], [System.String[]]) -Value @($null, [System.String[]] @()) + + $result.Item1 | Should -BeNullOrEmpty + $result.GetType().GetGenericArguments()[0].Name | Should -Be 'String' + } + } + } + + Context 'When the number of types does not match the number of values' { + It 'Should throw the correct error' { + InModuleScope -ScriptBlock { + { New-DscResultTuple -Type @([System.String]) -Value @('MyValue', 'MySecondValue') } | + Should -Throw -ExpectedMessage '*does not match the number of values*' + } + } + } +} From 56ced3beeef5295c47ce37963a78351d282861b4 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:11:37 +0200 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d1cc97..400aa96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ResourceBase` - new hidden helper methods that let a derived class + participate in the semantics of Microsoft DSC through one-liner static methods. + - `GetTestResult()` - returns the test result as the tuple + `[System.Tuple[System.Boolean, , System.String[]]]`. + - `GetSetResult([System.Boolean] $WhatIf)` - enforces the desired state and + returns the set result as the tuple + `[System.Tuple[, System.String[]]]`. In what-if mode the + predicted state is returned without modifying the system. + - `DeleteInstance()` - deletes the instance using the canonical DSC + property `_exist` (or `Ensure` as fallback for existing resources). + - `ExportInstances()` - override to support the export operation. + - `GetInstanceJsonSchema()` - returns the instance JSON schema built at + runtime using reflection, which also sees properties inherited from base + classes in other modules. +- `ResourceBase` - the method `Get()` now also evaluates the canonical DSC + property `_exist`, equivalent to the existing evaluation of `Ensure`. +- New private functions `New-DscResultTuple`, + `ConvertTo-DscResourceJsonSchema` and `ConvertTo-JsonSchemaTypeDefinition` + supporting the above. + ## [2.0.0] - 2025-12-28 ### Changed